Remove "You are already logged in" during authentication. Make other browser tabs to authenticate automatically when some browser tab successfully authenticate (#23517)

Closes #12406


Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Marek Posolda 2023-10-10 21:54:37 +02:00 committed by GitHub
parent 521db012f3
commit a6609bd969
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 465 additions and 216 deletions

View file

@ -79,6 +79,8 @@ public final class Constants {
public static final String EXECUTION = "execution"; public static final String EXECUTION = "execution";
public static final String CLIENT_ID = "client_id"; public static final String CLIENT_ID = "client_id";
public static final String TAB_ID = "tab_id"; public static final String TAB_ID = "tab_id";
public static final String SKIP_LOGOUT = "skip_logout";
public static final String KEY = "key"; public static final String KEY = "key";
public static final String KC_ACTION = "kc_action"; public static final String KC_ACTION = "kc_action";

View file

@ -47,6 +47,7 @@ import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorPageException; import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ServicesLogger; import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.BruteForceProtector; import org.keycloak.services.managers.BruteForceProtector;
import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.managers.UserSessionManager;
@ -68,6 +69,7 @@ import java.net.URI;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification; import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
@ -931,6 +933,11 @@ public class AuthenticationProcessor {
authSession.clearUserSessionNotes(); authSession.clearUserSessionNotes();
authSession.clearAuthNotes(); authSession.clearAuthNotes();
Set<String> requiredActions = authSession.getRequiredActions();
for (String reqAction : requiredActions) {
authSession.removeRequiredAction(reqAction);
}
authSession.setAction(CommonClientSessionModel.Action.AUTHENTICATE.name()); authSession.setAction(CommonClientSessionModel.Action.AUTHENTICATE.name());
authSession.setAuthNote(CURRENT_FLOW_PATH, flowPath); authSession.setAuthNote(CURRENT_FLOW_PATH, flowPath);

View file

@ -0,0 +1,109 @@
/*
* 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 jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.common.ClientConnection;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.util.CookieHelper;
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, RealmModel realm, RootAuthenticationSessionModel rootAuthSession) {
UriInfo uriInfo = session.getContext().getHttpRequest().getUri();
String path = AuthenticationManager.getRealmCookiePath(realm, uriInfo);
boolean secureOnly = realm.getSslRequired().isRequired(session.getContext().getConnection());
// 1 minute by default. Same timeout, which is used for client to complete "authorization code" flow
// Very short timeout should be OK as when this cookie is set, other existing browser tabs are supposed to be refreshed immediatelly by JS script
// and login user automatically. No need to have cookie living any further
int cookieMaxAge = realm.getAccessCodeLifespan();
AuthenticationStateCookie cookie = new AuthenticationStateCookie();
cookie.setAuthSessionId(rootAuthSession.getId());
cookie.setRemainingTabs(rootAuthSession.getAuthenticationSessions().keySet());
try {
String encoded = JsonSerialization.writeValueAsString(cookie);
logger.tracef("Generating new %s cookie. Cookie: %s, Cookie lifespan: %d", KC_AUTH_STATE, encoded, cookieMaxAge);
CookieHelper.addCookie(KC_AUTH_STATE, encoded, path, null, null, cookieMaxAge, secureOnly, false, session);
} catch (IOException ioe) {
throw new IllegalStateException("Exception thrown when encoding cookie", ioe);
}
}
public static void expireCookie(RealmModel realm, KeycloakSession session) {
UriInfo uriInfo = session.getContext().getHttpRequest().getUri();
String path = AuthenticationManager.getRealmCookiePath(realm, uriInfo);
boolean secureOnly = realm.getSslRequired().isRequired(session.getContext().getConnection());
CookieHelper.addCookie(KC_AUTH_STATE, "", path, null, null, 0, secureOnly, false, session);
}
@Override
public String toString() {
return new StringBuilder("AuthenticationStateCookie [ ")
.append("authSessionId=" + authSessionId)
.append(", remainingTabs=" + remainingTabs)
.append(" ]")
.toString();
}
}

View file

@ -46,6 +46,7 @@ import org.keycloak.common.util.ObjectUtil;
import org.keycloak.forms.login.LoginFormsPages; import org.keycloak.forms.login.LoginFormsPages;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean;
import org.keycloak.forms.login.freemarker.model.AuthenticationSessionBean;
import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodeInputLoginBean; import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodeInputLoginBean;
import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodesBean; import org.keycloak.forms.login.freemarker.model.RecoveryAuthnCodesBean;
import org.keycloak.forms.login.freemarker.model.ClientBean; import org.keycloak.forms.login.freemarker.model.ClientBean;
@ -226,6 +227,15 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
attributes.put("statusCode", status.getStatusCode()); attributes.put("statusCode", status.getStatusCode());
} }
if (!isDetachedAuthenticationSession()) {
if ((AuthenticationSessionModel.Action.AUTHENTICATE.name().equals(authenticationSession.getAction())) ||
(AuthenticationSessionModel.Action.REQUIRED_ACTIONS.name().equals(authenticationSession.getAction())) ||
(AuthenticationSessionModel.Action.OAUTH_GRANT.name().equals(authenticationSession.getAction()))) {
setAttribute("authenticationSession", new AuthenticationSessionBean(authenticationSession.getParentSession().getId(), authenticationSession.getTabId()));
}
}
switch (page) { switch (page) {
case LOGIN_CONFIG_TOTP: case LOGIN_CONFIG_TOTP:
attributes.put("totp", new TotpBean(session, realm, user, getTotpUriBuilder())); attributes.put("totp", new TotpBean(session, realm, user, getTotpUriBuilder()));

View file

@ -0,0 +1,43 @@
/*
* 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.model;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class AuthenticationSessionBean {
private final String authSessionId;
private final String tabId;
public AuthenticationSessionBean(String authSessionId, String tabId) {
this.authSessionId = authSessionId;
this.tabId = tabId;
}
public String getAuthSessionId() {
return authSessionId;
}
public String getTabId() {
return tabId;
}
}

View file

@ -57,7 +57,11 @@ public class UrlBean {
} }
public String getLoginRestartFlowUrl() { public String getLoginRestartFlowUrl() {
return Urls.realmLoginRestartPage(baseURI, realm).toString(); return Urls.realmLoginRestartPage(baseURI, realm, false).toString();
}
public String getSsoLoginInOtherTabsUrl() {
return Urls.realmLoginRestartPage(baseURI, realm, true).toString();
} }
public boolean hasAction() { public boolean hasAction() {

View file

@ -279,7 +279,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
session.clientPolicy().triggerOnEvent(new ImplicitHybridTokenResponse(authSession, clientSessionCtx, responseBuilder)); session.clientPolicy().triggerOnEvent(new ImplicitHybridTokenResponse(authSession, clientSessionCtx, responseBuilder));
} catch (ClientPolicyException cpe) { } catch (ClientPolicyException cpe) {
event.error(cpe.getError()); event.error(cpe.getError());
new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, true); new AuthenticationSessionManager(session).removeTabIdInAuthenticationSession(realm, authSession);
redirectUri.addParam(OAuth2Constants.ERROR_DESCRIPTION, cpe.getError()); redirectUri.addParam(OAuth2Constants.ERROR_DESCRIPTION, cpe.getError());
if (!clientConfig.isExcludeIssuerFromAuthResponse()) { if (!clientConfig.isExcludeIssuerFromAuthResponse()) {
redirectUri.addParam(OAuth2Constants.ISSUER, clientSession.getNote(OIDCLoginProtocol.ISSUER)); redirectUri.addParam(OAuth2Constants.ISSUER, clientSession.getNote(OIDCLoginProtocol.ISSUER));
@ -333,12 +333,9 @@ public class OIDCLoginProtocol implements LoginProtocol {
redirectUri.addParam(OAuth2Constants.STATE, state); redirectUri.addParam(OAuth2Constants.STATE, state);
} }
if (error == Error.PASSIVE_LOGIN_REQUIRED || error == Error.PASSIVE_INTERACTION_REQUIRED) { // Remove authenticationSession from current tab
// passive check error, just delete the tabId maintaining session and don't reset the restart cookie new AuthenticationSessionManager(session).removeTabIdInAuthenticationSession(realm, authSession);
new AuthenticationSessionManager(session).removeTabIdInAuthenticationSession(realm, authSession);
} else {
new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, true);
}
return redirectUri.build(); return redirectUri.build();
} }

View file

@ -584,8 +584,8 @@ public class TokenManager {
clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(authSession).getLevelOfAuthenticationFromCurrentAuthentication())); clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(new AcrStore(authSession).getLevelOfAuthenticationFromCurrentAuthentication()));
clientSession.setTimestamp(userSession.getLastSessionRefresh()); clientSession.setTimestamp(userSession.getLastSessionRefresh());
// Remove authentication session now // Remove authentication session now (just current tab, not whole "rootAuthenticationSession" in case we have more browser tabs with "authentications in progress")
new AuthenticationSessionManager(session).removeAuthenticationSession(userSession.getRealm(), authSession, true); new AuthenticationSessionManager(session).updateAuthenticationSessionAfterSuccessfulAuthentication(userSession.getRealm(), authSession);
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopeIds(clientSession, clientScopeIds, session); ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopeIds(clientSession, clientScopeIds, session);
return clientSessionCtx; return clientSessionCtx;

View file

@ -236,7 +236,8 @@ public class SamlProtocol implements LoginProtocol {
); );
} }
} finally { } finally {
new AuthenticationSessionManager(session).removeAuthenticationSession(realm, authSession, true); // Remove authenticationSession of current browser tab
new AuthenticationSessionManager(session).removeTabIdInAuthenticationSession(realm, authSession);
} }
} }

View file

@ -160,8 +160,9 @@ public class Urls {
return loginActionsBase(baseUri).path(LoginActionsService.class, "authenticate").build(realmName); return loginActionsBase(baseUri).path(LoginActionsService.class, "authenticate").build(realmName);
} }
public static URI realmLoginRestartPage(URI baseUri, String realmId) { public static URI realmLoginRestartPage(URI baseUri, String realmId, boolean skipLogout) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "restartSession") return loginActionsBase(baseUri).path(LoginActionsService.class, "restartSession")
.queryParam(Constants.SKIP_LOGOUT, String.valueOf(skipLogout))
.build(realmId); .build(realmId);
} }

View file

@ -19,24 +19,30 @@ package org.keycloak.services.managers;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue; import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
import org.keycloak.common.util.Time;
import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.forms.login.freemarker.AuthenticationStateCookie;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.RestartLoginCookie; import org.keycloak.protocol.RestartLoginCookie;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.util.CookieHelper; import org.keycloak.services.util.CookieHelper;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.CommonClientSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.sessions.StickySessionEncoderProvider; import org.keycloak.sessions.StickySessionEncoderProvider;
import jakarta.ws.rs.core.UriInfo; import jakarta.ws.rs.core.UriInfo;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.keycloak.authentication.AuthenticationProcessor.CURRENT_FLOW_PATH;
import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification; import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForModification;
/** /**
@ -215,25 +221,56 @@ public class AuthenticationSessionManager {
public void removeAuthenticationSession(RealmModel realm, AuthenticationSessionModel authSession, boolean expireRestartCookie) { public void removeAuthenticationSession(RealmModel realm, AuthenticationSessionModel authSession, boolean expireRestartCookie) {
RootAuthenticationSessionModel rootAuthSession = authSession.getParentSession(); RootAuthenticationSessionModel rootAuthSession = authSession.getParentSession();
log.debugf("Removing authSession '%s'. Expire restart cookie: %b", rootAuthSession.getId(), expireRestartCookie); log.debugf("Removing root authSession '%s'. Expire restart cookie: %b", rootAuthSession.getId(), expireRestartCookie);
session.authenticationSessions().removeRootAuthenticationSession(realm, rootAuthSession); session.authenticationSessions().removeRootAuthenticationSession(realm, rootAuthSession);
// expire restart cookie // expire restart cookie
if (expireRestartCookie) { if (expireRestartCookie) {
UriInfo uriInfo = session.getContext().getUri(); UriInfo uriInfo = session.getContext().getUri();
RestartLoginCookie.expireRestartCookie(realm, uriInfo, session); RestartLoginCookie.expireRestartCookie(realm, uriInfo, session);
AuthenticationStateCookie.expireCookie(realm, session);
// With browser session, this makes sure that info/error pages will be rendered correctly when locale is changed on them // 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(); session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
} }
} }
public void removeTabIdInAuthenticationSession(RealmModel realm, AuthenticationSessionModel authSession) { /**
* Remove authentication session from root session. Possibly remove whole root authentication session if there are no other browser tabs
* @param realm
* @param authSession
* @return true if whole root authentication session was removed. False just if single tab was removed
*/
public boolean removeTabIdInAuthenticationSession(RealmModel realm, AuthenticationSessionModel authSession) {
RootAuthenticationSessionModel rootAuthSession = authSession.getParentSession(); RootAuthenticationSessionModel rootAuthSession = authSession.getParentSession();
rootAuthSession.removeAuthenticationSessionByTabId(authSession.getTabId()); rootAuthSession.removeAuthenticationSessionByTabId(authSession.getTabId());
if (rootAuthSession.getAuthenticationSessions().isEmpty()) { if (rootAuthSession.getAuthenticationSessions().isEmpty()) {
// no more tabs, remove the session completely // no more tabs, remove the session completely
removeAuthenticationSession(realm, authSession, false); removeAuthenticationSession(realm, authSession, true);
return true;
} else {
return false;
}
}
/**
* This happens when one browser tab successfully finished authentication (including required actions and consent screen if applicable)
* Just authenticationSession of the current browser tab is removed from "root authentication session" and other tabs are kept, so
* authentication can be automatically finished in other browser tabs (typically with authChecker.js javascript)
*
* @param realm
* @param authSession
*/
public void updateAuthenticationSessionAfterSuccessfulAuthentication(RealmModel realm, AuthenticationSessionModel authSession) {
// TODO: The authentication session might need to be expired in short interval (realm accessCodeLifespan, which is 1 minute by default). That should be sufficient for other browser tabs
// to finish authentication and at the same time we won't need to keep authentication sessions in storage longer than needed
boolean removedRootAuthSession = removeTabIdInAuthenticationSession(realm, authSession);
if (!removedRootAuthSession) {
RootAuthenticationSessionModel rootAuthSession = authSession.getParentSession();
log.tracef("Removed authentication session of root session '%s' with tabId '%s'. But there are remaining tabs in the root session", rootAuthSession.getId(), authSession.getTabId());
AuthenticationStateCookie.generateAndSetCookie(session, realm, rootAuthSession);
} }
} }

View file

@ -220,7 +220,8 @@ public class LoginActionsService {
@GET @GET
public Response restartSession(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead public Response restartSession(@QueryParam(AUTH_SESSION_ID) String authSessionId, // optional, can get from cookie instead
@QueryParam(Constants.CLIENT_ID) String clientId, @QueryParam(Constants.CLIENT_ID) String clientId,
@QueryParam(Constants.TAB_ID) String tabId) { @QueryParam(Constants.TAB_ID) String tabId,
@QueryParam(Constants.SKIP_LOGOUT) String skipLogout) {
event.event(EventType.RESTART_AUTHENTICATION); event.event(EventType.RESTART_AUTHENTICATION);
SessionCodeChecks checks = new SessionCodeChecks(realm, session.getContext().getUri(), request, clientConnection, session, event, authSessionId, null, null, clientId, tabId, null); SessionCodeChecks checks = new SessionCodeChecks(realm, session.getContext().getUri(), request, clientConnection, session, event, authSessionId, null, null, clientId, tabId, null);
@ -234,12 +235,14 @@ public class LoginActionsService {
flowPath = AUTHENTICATE_PATH; flowPath = AUTHENTICATE_PATH;
} }
// See if we already have userSession attached to authentication session. This means restart of authentication session during re-authentication if (!Boolean.parseBoolean(skipLogout)) {
// We logout userSession in this case // See if we already have userSession attached to authentication session. This means restart of authentication session during re-authentication
UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession); // We logout userSession in this case
if (userSession != null) { UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authSession);
logger.debugf("Logout of user session %s when restarting flow during re-authentication", userSession.getId()); if (userSession != null) {
AuthenticationManager.backchannelLogout(session, userSession, false); logger.debugf("Logout of user session %s when restarting flow during re-authentication", userSession.getId());
AuthenticationManager.backchannelLogout(session, userSession, false);
}
} }
AuthenticationProcessor.resetFlow(authSession, flowPath); AuthenticationProcessor.resetFlow(authSession, flowPath);

View file

@ -53,6 +53,10 @@ public class AppPage extends AbstractPage {
clickLink(accountLink); clickLink(accountLink);
} }
public WebElement getAccountLink() {
return accountLink;
}
public enum RequestType { public enum RequestType {
AUTH_RESPONSE, LOGOUT_REQUEST, APP_REQUEST AUTH_RESPONSE, LOGOUT_REQUEST, APP_REQUEST
} }

View file

@ -17,12 +17,13 @@
package org.keycloak.testsuite.forms; package org.keycloak.testsuite.forms;
import static org.junit.Assert.fail; import static org.hamcrest.MatcherAssert.assertThat;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.net.URL; import java.net.URL;
import org.hamcrest.MatcherAssert; import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
@ -58,7 +59,8 @@ import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
import org.openqa.selenium.NoSuchElementException; import org.keycloak.testsuite.util.WaitUtils;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
/** /**
* Tries to simulate testing with multiple browser tabs * Tries to simulate testing with multiple browser tabs
@ -136,34 +138,43 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
@Test @Test
public void multipleTabsParallelLoginTest() { public void multipleTabsParallelLoginTest() {
oauth.openLoginForm(); try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
loginPage.assertCurrent(); assertThat(tabUtil.getCountOfTabs(), Matchers.is(1));
oauth.openLoginForm();
loginPage.assertCurrent();
loginPage.login("login-test", "password"); loginPage.login("login-test", "password");
updatePasswordPage.assertCurrent(); updatePasswordPage.assertCurrent();
String tab1Url = driver.getCurrentUrl(); // Simulate login in different browser tab tab2. I will be on loginPage again.
tabUtil.newTab(oauth.getLoginFormUrl());
assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2));
// Simulate login in different browser tab tab2. I will be on loginPage again. oauth.openLoginForm();
oauth.openLoginForm(); loginPage.assertCurrent();
loginPage.assertCurrent();
// Login in tab2 // Login in tab2
loginPage.login("login-test", "password"); loginPage.login("login-test", "password");
updatePasswordPage.assertCurrent(); updatePasswordPage.assertCurrent();
updatePasswordPage.changePassword("password", "password"); updatePasswordPage.changePassword("password", "password");
updateProfilePage.prepareUpdate().firstName("John").lastName("Doe3") updateProfilePage.prepareUpdate().firstName("John").lastName("Doe3")
.email("john@doe3.com").submit(); .email("john@doe3.com").submit();
appPage.assertCurrent(); appPage.assertCurrent();
// Try to go back to tab 1. We should have ALREADY_LOGGED_IN info page // Try to go back to tab 1. We should be logged-in automatically
driver.navigate().to(tab1Url); tabUtil.closeTab(1);
infoPage.assertCurrent(); assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1));
Assert.assertEquals("You are already logged in.", infoPage.getInfo());
infoPage.clickBackToApplicationLink(); // Should be back on tab1
appPage.assertCurrent(); 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();
appPage.assertCurrent();
}
} }
@Test @Test
@ -448,49 +459,56 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
// KEYCLOAK-12161 // KEYCLOAK-12161
@Test @Test
public void testEmptyBaseUrl() throws Exception { public void testEmptyBaseUrl() throws Exception {
String clientUuid = KeycloakModelUtils.generateId(); try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
ClientRepresentation emptyBaseclient = ClientBuilder.create() assertThat(tabUtil.getCountOfTabs(), Matchers.is(1));
.clientId("empty-baseurl-client")
.id(clientUuid)
.enabled(true)
.baseUrl("")
.addRedirectUri("*")
.secret("password")
.build();
testRealm().clients().create(emptyBaseclient);
getCleanup().addClientUuid(clientUuid);
oauth.clientId("empty-baseurl-client"); String clientUuid = KeycloakModelUtils.generateId();
oauth.openLoginForm(); ClientRepresentation emptyBaseclient = ClientBuilder.create()
loginPage.assertCurrent(); .clientId("empty-baseurl-client")
.id(clientUuid)
.enabled(true)
.baseUrl("")
.addRedirectUri("*")
.secret("password")
.build();
testRealm().clients().create(emptyBaseclient);
getCleanup().addClientUuid(clientUuid);
loginPage.login("login-test", "password"); oauth.clientId("empty-baseurl-client");
updatePasswordPage.assertCurrent(); oauth.openLoginForm();
loginPage.assertCurrent();
String tab1Url = driver.getCurrentUrl(); loginPage.login("login-test", "password");
updatePasswordPage.assertCurrent();
// Simulate login in different browser tab tab2. I will be on loginPage again. String tab1Url = driver.getCurrentUrl();
oauth.openLoginForm();
loginPage.assertCurrent();
// Login in tab2 // Simulate login in different browser tab tab2. I will be on loginPage again.
loginPage.login("login-test", "password"); tabUtil.newTab(oauth.getLoginFormUrl());
updatePasswordPage.assertCurrent(); assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2));
updatePasswordPage.changePassword("password", "password"); loginPage.assertCurrent();
updateProfilePage.prepareUpdate().firstName("John").lastName("Doe3")
.email("john@doe3.com").submit();
appPage.assertCurrent();
// Try to go back to tab 1. We should have ALREADY_LOGGED_IN info page // Login in tab2
driver.navigate().to(tab1Url); loginPage.login("login-test", "password");
infoPage.assertCurrent(); updatePasswordPage.assertCurrent();
Assert.assertEquals("You are already logged in.", infoPage.getInfo());
try { updatePasswordPage.changePassword("password", "password");
infoPage.clickBackToApplicationLink(); updateProfilePage.prepareUpdate().firstName("John").lastName("Doe3")
fail(); .email("john@doe3.com").submit();
appPage.assertCurrent();
// Try to go back to tab 1. We should be logged-in automatically
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();
appPage.assertCurrent();
} }
catch (NoSuchElementException ex) {}
} }
} }

View file

@ -21,8 +21,8 @@ import org.jboss.arquillian.drone.api.annotation.Drone;
import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken; import org.keycloak.authentication.actiontoken.resetcred.ResetCredentialsActionToken;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
@ -30,14 +30,13 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.DisableFeature;
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
import org.keycloak.testsuite.federation.kerberos.AbstractKerberosTest; import org.keycloak.testsuite.federation.kerberos.AbstractKerberosTest;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
@ -82,10 +81,8 @@ import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement; import org.openqa.selenium.WebElement;
import org.openqa.selenium.firefox.FirefoxDriver; import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.support.ui.ExpectedConditions; import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.*; import static org.junit.Assert.*;
@ -1143,34 +1140,6 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
assertEquals("Invalid username or password.", errorPage.getError()); assertEquals("Invalid username or password.", errorPage.getError());
} }
@Test
public void resetPasswordLinkNewTabAndProperRedirectAccount() throws IOException {
final String REQUIRED_URI = getAuthServerRoot() + "realms/test/account/login-redirect?path=applications";
final String REDIRECT_URI = getAuthServerRoot() + "realms/test/account/login-redirect?path=applications";
final String CLIENT_ID = "account";
final String ACCOUNT_MANAGEMENT_TITLE = "Keycloak Account Management";
try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
assertThat(tabUtil.getCountOfTabs(), Matchers.is(1));
oauth.redirectUri(REDIRECT_URI);
oauth.clientId(CLIENT_ID);
loginPage.open();
resetPasswordTwiceInNewTab(defaultUser, CLIENT_ID, false, REDIRECT_URI, REQUIRED_URI);
assertThat(driver.getTitle(), Matchers.equalTo(ACCOUNT_MANAGEMENT_TITLE));
String logoutUrl = oauth.getLogoutUrl().build();
driver.navigate().to(logoutUrl);
logoutConfirmPage.assertCurrent();
logoutConfirmPage.confirmLogout();
loginPage.open();
resetPasswordTwiceInNewTab(defaultUser, CLIENT_ID, true, REDIRECT_URI, REQUIRED_URI);
assertThat(driver.getTitle(), Matchers.equalTo(ACCOUNT_MANAGEMENT_TITLE));
}
}
@Test @Test
public void resetPasswordLinkNewTabAndProperRedirectClient() throws IOException { public void resetPasswordLinkNewTabAndProperRedirectClient() throws IOException {
final String REDIRECT_URI = getAuthServerRoot() + "realms/master/app/auth"; final String REDIRECT_URI = getAuthServerRoot() + "realms/master/app/auth";
@ -1184,7 +1153,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
assertThat(tabUtil.getCountOfTabs(), Matchers.is(1)); assertThat(tabUtil.getCountOfTabs(), Matchers.is(1));
loginPage.open(); loginPage.open();
resetPasswordTwiceInNewTab(defaultUser, CLIENT_ID, false, REDIRECT_URI); resetPasswordInNewTab(defaultUser, CLIENT_ID, REDIRECT_URI);
assertThat(driver.getCurrentUrl(), Matchers.containsString(REDIRECT_URI)); assertThat(driver.getCurrentUrl(), Matchers.containsString(REDIRECT_URI));
String logoutUrl = oauth.getLogoutUrl().build(); String logoutUrl = oauth.getLogoutUrl().build();
@ -1193,7 +1162,7 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
logoutConfirmPage.confirmLogout(); logoutConfirmPage.confirmLogout();
loginPage.open(); loginPage.open();
resetPasswordTwiceInNewTab(defaultUser, CLIENT_ID, true, REDIRECT_URI); resetPasswordInNewTab(defaultUser, CLIENT_ID, REDIRECT_URI);
assertThat(driver.getCurrentUrl(), Matchers.containsString(REDIRECT_URI)); assertThat(driver.getCurrentUrl(), Matchers.containsString(REDIRECT_URI));
} }
} }
@ -1274,114 +1243,72 @@ public class ResetPasswordTest extends AbstractTestRealmKeycloakTest {
submit.click(); submit.click();
} }
private void resetPasswordTwiceInNewTab(UserRepresentation user, String clientId, boolean shouldLogOut, String redirectUri) throws IOException { private void resetPasswordInNewTab(UserRepresentation user, String clientId, String redirectUri) throws IOException {
resetPasswordTwiceInNewTab(user, clientId, shouldLogOut, redirectUri, redirectUri); try (BrowserTabUtil browserTabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
} events.clear();
private void resetPasswordTwiceInNewTab(UserRepresentation user, String clientId, boolean shouldLogOut, String redirectUri, String requiredUri) throws IOException { final int emailCount = greenMail.getReceivedMessages().length;
events.clear();
updateForgottenPassword(user, clientId, redirectUri, requiredUri);
if (shouldLogOut) { // In tab1 start "Forget password" flow and make sure the email is sent
String sessionId = events.expectLogin().user(user.getId()).detail(Details.USERNAME, user.getUsername()) loginPage.assertCurrent();
loginPage.resetPassword();
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword(user.getUsername());
WaitUtils.waitForPageToLoad();
assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
String tab1Url = driver.getCurrentUrl();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.user(user.getId())
.client(clientId)
.detail(Details.REDIRECT_URI, redirectUri)
.detail(Details.USERNAME, user.getUsername())
.detail(Details.EMAIL, user.getEmail())
.session((String) null)
.assertEvent();
assertEquals(emailCount + 1, greenMail.getReceivedMessages().length);
final MimeMessage message = greenMail.getReceivedMessages()[emailCount];
final String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
// Open link from email in the new tab
browserTabUtil.newTab(changePasswordUrl.trim());
// Change password in tab2
changePasswordOnUpdatePage(driver);
events.expectRequiredAction(EventType.UPDATE_PASSWORD)
.detail(Details.REDIRECT_URI, redirectUri) .detail(Details.REDIRECT_URI, redirectUri)
.client(clientId) .client(clientId)
.assertEvent().getSessionId(); .user(user.getId()).detail(Details.USERNAME, user.getUsername()).assertEvent();
String logoutUrl = oauth.getLogoutUrl().build(); // User should be authenticated in current tab (tab2)
driver.navigate().to(logoutUrl); WaitUtils.waitUntilElement(appPage.getAccountLink()).is().clickable();
logoutConfirmPage.assertCurrent(); appPage.assertCurrent();
logoutConfirmPage.confirmLogout(); assertThat(driver.getCurrentUrl(), Matchers.containsString(redirectUri));
events.expectLogout(sessionId) // Close tab2
.client("account") assertThat(browserTabUtil.getCountOfTabs(), Matchers.equalTo(2));
.user(user.getId()) browserTabUtil.closeTab(1);
.removeDetail(Details.REDIRECT_URI) assertThat(browserTabUtil.getCountOfTabs(), Matchers.equalTo(1));
.assertEvent();
}
BrowserTabUtil util = BrowserTabUtil.getInstanceAndSetEnv(driver); if (driver instanceof HtmlUnitDriver) {
assertThat(util.getCountOfTabs(), Matchers.equalTo(2)); // With HtmlUnit, authChecker javascript doesn't work. Hence need to manually trigger "reset flow" endpoint
util.closeTab(1); KeycloakUriBuilder builder = KeycloakUriBuilder.fromUri(tab1Url);
assertThat(util.getCountOfTabs(), Matchers.equalTo(1)); String resetFlowPath = builder
.replacePath(builder.getPath().substring(0, builder.getPath().lastIndexOf('/') + 1) + LoginActionsService.RESTART_PATH)
.queryParam(Constants.SKIP_LOGOUT, "true")
.build().toString();
driver.navigate().to(resetFlowPath);
}
if (shouldLogOut) { // User should be automatically authenticated in tab1 as well (due authChecker.js on real browsers like FF or Chrome)
final ClientRepresentation client = testRealm().clients() WaitUtils.waitUntilElement(appPage.getAccountLink()).is().clickable();
.findByClientId(clientId) appPage.assertCurrent();
.stream()
.findFirst()
.orElse(null);
assertThat(client, Matchers.notNullValue());
updateForgottenPassword(user, clientId, getValidRedirectUriWithRootUrl(client.getRootUrl(), client.getRedirectUris()));
} else {
doForgotPassword(user.getUsername());
}
}
private void updateForgottenPassword(UserRepresentation user, String clientId, String redirectUri) throws IOException {
updateForgottenPassword(user, clientId, redirectUri, redirectUri);
}
private void updateForgottenPassword(UserRepresentation user, String clientId, String redirectUri, String requiredUri) throws IOException {
final int emailCount = greenMail.getReceivedMessages().length;
doForgotPassword(user.getUsername());
assertEquals("You should receive an email shortly with further instructions.", loginPage.getSuccessMessage());
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.user(user.getId())
.client(clientId)
.detail(Details.REDIRECT_URI, redirectUri)
.detail(Details.USERNAME, user.getUsername())
.detail(Details.EMAIL, user.getEmail())
.session((String) null)
.assertEvent();
assertEquals(emailCount + 1, greenMail.getReceivedMessages().length);
final MimeMessage message = greenMail.getReceivedMessages()[emailCount];
final String changePasswordUrl = MailUtils.getPasswordResetEmailLink(message);
BrowserTabUtil util = BrowserTabUtil.getInstanceAndSetEnv(driver);
util.newTab(changePasswordUrl.trim());
changePasswordOnUpdatePage(driver);
events.expectRequiredAction(EventType.UPDATE_PASSWORD)
.detail(Details.REDIRECT_URI, redirectUri)
.client(clientId)
.user(user.getId()).detail(Details.USERNAME, user.getUsername()).assertEvent();
assertThat(driver.getCurrentUrl(), Matchers.containsString(requiredUri));
}
private void doForgotPassword(String username) {
loginPage.assertCurrent();
loginPage.resetPassword();
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword(username);
WaitUtils.waitForPageToLoad();
}
private String getValidRedirectUriWithRootUrl(String rootUrl, Collection<String> redirectUris) {
final boolean isRootUrlValid = isValidUrl(rootUrl);
return redirectUris.stream()
.map(uri -> isRootUrlValid && uri.startsWith("/") ? rootUrl + uri : uri)
.map(uri -> uri.startsWith("/") ? OAuthClient.AUTH_SERVER_ROOT + uri : uri)
.map(RedirectUtils::validateRedirectUriWildcard)
.findFirst()
.orElse(null);
}
private boolean isValidUrl(String url) {
try {
new URL(url);
return true;
} catch (MalformedURLException e) {
return false;
} }
} }
} }

View file

@ -0,0 +1,75 @@
const CHECK_INTERVAL_MILLISECS = 2000;
const initialSession = getSession();
export function checkCookiesAndSetTimer(authSessionId, tabId, loginRestartUrl) {
if (initialSession) {
// We started with a session, so there is nothing to do, exit.
return;
}
const session = getSession();
if (!session) {
// The session is not present, check again later.
setTimeout(
() => checkCookiesAndSetTimer(authSessionId, tabId, loginRestartUrl),
CHECK_INTERVAL_MILLISECS
);
} else {
// The session is present, check the auth state.
checkAuthState(authSessionId, tabId, 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(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) {
const cookies = new Map();
for (const cookie of document.cookie.split(";")) {
const [key, value] = cookie.split("=").map((value) => value.trim());
cookies.set(key, value);
}
return cookies.get(name) ?? null;
}

View file

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