CVE-2025-12390
Description
A flaw was found in Keycloak. In Keycloak where a user can accidentally get access to another user's session if both use the same device and browser. This happens because Keycloak sometimes reuses session identifiers and doesn’t clean up properly during logout when browser cookies are missing. As a result, one user may receive tokens that belong to another user.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.keycloak:keycloak-servicesMaven | < 26.0.0 | 26.0.0 |
Affected products
1Patches
4b46fab230824Remove root auth session after backchannel logout
8 files changed · +65 −40
services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java+1 −2 modified@@ -308,8 +308,7 @@ public static BackchannelLogoutResponse backchannelLogout(KeycloakSession sessio checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { logger.tracef("Removing logout session '%s' after backchannel logout", logoutAuthSession.getParentSession().getId()); - RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); - rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); + session.authenticationSessions().removeRootAuthenticationSession(realm, logoutAuthSession.getParentSession()); } userSession.setState(UserSessionModel.State.LOGGED_OUT);
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/BrowserTabUtil.java+5 −1 modified@@ -79,6 +79,10 @@ public static BrowserTabUtil getInstanceAndSetEnv(WebDriver driver) { return instance; } + public static void cleanup() { + instances = new ArrayList<>(); + } + public WebDriver getDriver() { return driver; } @@ -155,4 +159,4 @@ private void assertValidIndex(int index) { public void close() { destroy(); } -} \ No newline at end of file +}
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java+2 −0 modified@@ -55,6 +55,7 @@ import org.keycloak.testsuite.auth.page.login.UpdatePassword; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.CryptoInitRule; import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; @@ -258,6 +259,7 @@ public void afterAbstractKeycloakTest() throws Exception { // Remove all browsers from queue DroneUtils.resetQueue(); + BrowserTabUtil.cleanup(); } protected TestCleanup getCleanup(String realmName) {
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ReAuthenticationTest.java+37 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.FederatedIdentityBuilder; import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; @@ -366,6 +367,42 @@ public void loginAfterExpiredUserSession() { realmsResouce().realm(rep.getRealm()).update(rep); } + @Test + public void loginAfterLogoutWithDifferentSessionId() { + BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver); + + assertThat(tabUtil.getCountOfTabs(), Matchers.is(1)); + oauth.openLoginForm(); + loginPage.assertCurrent(); + + tabUtil.newTab(oauth.getLoginFormUrl()); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2)); + oauth.openLoginForm(); + + tabUtil.closeTab(tabUtil.getCountOfTabs() - 1); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); + + tabUtil.switchToTab(0); + loginPage.assertCurrent(); + + loginPage.login("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken1 = oauth.verifyToken(response1.getAccessToken()); + + oauth.doLogout(response1.getRefreshToken(), "password"); + + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response2 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken2 = oauth.verifyToken(response2.getAccessToken()); + + Assert.assertNotEquals(accessToken1.getId(), accessToken2.getId()); + Assert.assertNotEquals(accessToken1.getSessionId(), accessToken2.getSessionId()); + } + private void setupIdentityFirstFlow() { String newFlowAlias = "browser - identity first"; testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java+3 −20 modified@@ -146,30 +146,14 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); Assert.assertFalse(loginPage.isCurrent()); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); - // POST logout with token should fail - try (CloseableHttpResponse response = oauth.doLogout(refreshToken1, "password")) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - - String logoutUrl = oauth.getLogoutUrl() - .idTokenHint(accessTokenResponse.getIdToken()) - .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) - .build(); - - // GET logout with ID token should fail as well - try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); - CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - // finally POST logout with VALID token should succeed try (CloseableHttpResponse response = oauth.doLogout(tokenResponse2.getRefreshToken(), "password")) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT)); @@ -178,7 +162,6 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro } } - @Test public void postLogoutFailWithCredentialsOfDifferentClient() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -247,7 +230,7 @@ private void backchannelLogoutRequest(String expectedRefreshAlg, String expected .idTokenHint(idTokenString) .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) .build(); - + try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java+6 −6 modified@@ -1070,8 +1070,8 @@ public void refreshTokenAfterUserLogoutAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -1104,8 +1104,8 @@ public void refreshTokenAfterAdminLogoutAllAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -1137,8 +1137,8 @@ public void refreshTokenAfterUserAdminLogoutEndpointAndLoginAgain() { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java+2 −2 modified@@ -250,8 +250,8 @@ public void testIntrospectRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+9 −9 modified@@ -207,7 +207,7 @@ public void testSuccess_postMethod_body() throws Exception { client.close(); } } - + @Test public void testSuccess_postMethod_charset_body() throws Exception { Client client = AdminClientUtil.createResteasyClient(); @@ -531,7 +531,7 @@ public void testSuccessSignedResponsePS256() throws Exception { public void testSuccessSignedResponseRS256AcceptJWT() throws Exception { testSuccessSignedResponse(Algorithm.RS256, MediaType.APPLICATION_JWT); } - + @Test public void testSessionExpired() { Client client = AdminClientUtil.createResteasyClient(); @@ -607,8 +607,8 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent()); @@ -630,7 +630,7 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { response.close(); events.expect(EventType.USER_INFO_REQUEST_ERROR) - .error(Errors.INVALID_TOKEN) + .error(Errors.USER_SESSION_NOT_FOUND) .user(Matchers.nullValue(String.class)) .session(Matchers.nullValue(String.class)) .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) @@ -1088,23 +1088,23 @@ private void testRolesAreNotInUserInfoResponse(UserInfo userInfo) { assertNull(userInfo.getOtherClaims().get("realm_access")); assertNull(userInfo.getOtherClaims().get("resource_access")); } - + @Test public void test_noContentType() throws Exception { Client client = AdminClientUtil.createResteasyClient(); try { AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client); - + WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client); Response response = userInfoTarget.request() .header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getToken()) .build("POST") .invoke(); - + Assert.assertEquals(200, response.getStatus()); Assert.assertEquals("OK", response.getStatusInfo().toString()); - + } finally { client.close(); }
ef75a4dc50aaRemove root auth session after backchannel logout
8 files changed · +65 −40
services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java+1 −2 modified@@ -307,8 +307,7 @@ public static BackchannelLogoutResponse backchannelLogout(KeycloakSession sessio checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { logger.tracef("Removing logout session '%s' after backchannel logout", logoutAuthSession.getParentSession().getId()); - RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); - rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); + session.authenticationSessions().removeRootAuthenticationSession(realm, logoutAuthSession.getParentSession()); } userSession.setState(UserSessionModel.State.LOGGED_OUT);
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/BrowserTabUtil.java+5 −1 modified@@ -79,6 +79,10 @@ public static BrowserTabUtil getInstanceAndSetEnv(WebDriver driver) { return instance; } + public static void cleanup() { + instances = new ArrayList<>(); + } + public WebDriver getDriver() { return driver; } @@ -155,4 +159,4 @@ private void assertValidIndex(int index) { public void close() { destroy(); } -} \ No newline at end of file +}
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java+2 −0 modified@@ -60,6 +60,7 @@ import org.keycloak.testsuite.auth.page.login.UpdatePassword; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.CryptoInitRule; import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; @@ -254,6 +255,7 @@ public void afterAbstractKeycloakTest() throws Exception { // Remove all browsers from queue DroneUtils.resetQueue(); + BrowserTabUtil.cleanup(); } protected TestCleanup getCleanup(String realmName) {
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ReAuthenticationTest.java+37 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.FederatedIdentityBuilder; import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; @@ -365,6 +366,42 @@ public void loginAfterExpiredUserSession() { realmsResouce().realm(rep.getRealm()).update(rep); } + @Test + public void loginAfterLogoutWithDifferentSessionId() { + BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver); + + Assert.assertThat(tabUtil.getCountOfTabs(), Matchers.is(1)); + oauth.openLoginForm(); + loginPage.assertCurrent(); + + tabUtil.newTab(oauth.getLoginFormUrl()); + Assert.assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2)); + oauth.openLoginForm(); + + tabUtil.closeTab(tabUtil.getCountOfTabs() - 1); + Assert.assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); + + tabUtil.switchToTab(0); + loginPage.assertCurrent(); + + loginPage.login("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken1 = oauth.verifyToken(response1.getAccessToken()); + + oauth.doLogout(response1.getRefreshToken(), "password"); + + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response2 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken2 = oauth.verifyToken(response2.getAccessToken()); + + Assert.assertNotEquals(accessToken1.getId(), accessToken2.getId()); + Assert.assertNotEquals(accessToken1.getSessionId(), accessToken2.getSessionId()); + } + private void setupIdentityFirstFlow() { String newFlowAlias = "browser - identity first"; testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java+3 −20 modified@@ -145,30 +145,14 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); Assert.assertFalse(loginPage.isCurrent()); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); - // POST logout with token should fail - try (CloseableHttpResponse response = oauth.doLogout(refreshToken1, "password")) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - - String logoutUrl = oauth.getLogoutUrl() - .idTokenHint(accessTokenResponse.getIdToken()) - .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) - .build(); - - // GET logout with ID token should fail as well - try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); - CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - // finally POST logout with VALID token should succeed try (CloseableHttpResponse response = oauth.doLogout(tokenResponse2.getRefreshToken(), "password")) { assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT)); @@ -177,7 +161,6 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro } } - @Test public void postLogoutFailWithCredentialsOfDifferentClient() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -246,7 +229,7 @@ private void backchannelLogoutRequest(String expectedRefreshAlg, String expected .idTokenHint(idTokenString) .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) .build(); - + try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java+6 −6 modified@@ -831,8 +831,8 @@ public void refreshTokenAfterUserLogoutAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -865,8 +865,8 @@ public void refreshTokenAfterAdminLogoutAllAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -898,8 +898,8 @@ public void refreshTokenAfterUserAdminLogoutEndpointAndLoginAgain() { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java+2 −2 modified@@ -233,8 +233,8 @@ public void testIntrospectRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+9 −9 modified@@ -207,7 +207,7 @@ public void testSuccess_postMethod_body() throws Exception { client.close(); } } - + @Test public void testSuccess_postMethod_charset_body() throws Exception { Client client = AdminClientUtil.createResteasyClient(); @@ -516,7 +516,7 @@ public void testSuccessSignedResponsePS256() throws Exception { public void testSuccessSignedResponseRS256AcceptJWT() throws Exception { testSuccessSignedResponse(Algorithm.RS256, MediaType.APPLICATION_JWT); } - + @Test public void testSessionExpired() { Client client = AdminClientUtil.createResteasyClient(); @@ -592,8 +592,8 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent()); @@ -615,7 +615,7 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { response.close(); events.expect(EventType.USER_INFO_REQUEST_ERROR) - .error(Errors.INVALID_TOKEN) + .error(Errors.USER_SESSION_NOT_FOUND) .user(Matchers.nullValue(String.class)) .session(Matchers.nullValue(String.class)) .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) @@ -1073,23 +1073,23 @@ private void testRolesAreNotInUserInfoResponse(UserInfo userInfo) { assertNull(userInfo.getOtherClaims().get("realm_access")); assertNull(userInfo.getOtherClaims().get("resource_access")); } - + @Test public void test_noContentType() throws Exception { Client client = AdminClientUtil.createResteasyClient(); try { AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client); - + WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client); Response response = userInfoTarget.request() .header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getToken()) .build("POST") .invoke(); - + Assert.assertEquals(200, response.getStatus()); Assert.assertEquals("OK", response.getStatusInfo().toString()); - + } finally { client.close(); }
d82438a611f2Remove root auth session after backchannel logout
8 files changed · +65 −40
services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java+1 −2 modified@@ -307,8 +307,7 @@ public static BackchannelLogoutResponse backchannelLogout(KeycloakSession sessio checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { logger.tracef("Removing logout session '%s' after backchannel logout", logoutAuthSession.getParentSession().getId()); - RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); - rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); + session.authenticationSessions().removeRootAuthenticationSession(realm, logoutAuthSession.getParentSession()); } userSession.setState(UserSessionModel.State.LOGGED_OUT);
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/BrowserTabUtil.java+5 −1 modified@@ -79,6 +79,10 @@ public static BrowserTabUtil getInstanceAndSetEnv(WebDriver driver) { return instance; } + public static void cleanup() { + instances = new ArrayList<>(); + } + public WebDriver getDriver() { return driver; } @@ -155,4 +159,4 @@ private void assertValidIndex(int index) { public void close() { destroy(); } -} \ No newline at end of file +}
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java+2 −0 modified@@ -56,6 +56,7 @@ import org.keycloak.testsuite.auth.page.login.UpdatePassword; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.CryptoInitRule; import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; @@ -251,6 +252,7 @@ public void afterAbstractKeycloakTest() throws Exception { // Remove all browsers from queue DroneUtils.resetQueue(); + BrowserTabUtil.cleanup(); } protected TestCleanup getCleanup(String realmName) {
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ReAuthenticationTest.java+37 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.FederatedIdentityBuilder; import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; @@ -366,6 +367,42 @@ public void loginAfterExpiredUserSession() { realmsResouce().realm(rep.getRealm()).update(rep); } + @Test + public void loginAfterLogoutWithDifferentSessionId() { + BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver); + + assertThat(tabUtil.getCountOfTabs(), Matchers.is(1)); + oauth.openLoginForm(); + loginPage.assertCurrent(); + + tabUtil.newTab(oauth.getLoginFormUrl()); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2)); + oauth.openLoginForm(); + + tabUtil.closeTab(tabUtil.getCountOfTabs() - 1); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); + + tabUtil.switchToTab(0); + loginPage.assertCurrent(); + + loginPage.login("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken1 = oauth.verifyToken(response1.getAccessToken()); + + oauth.doLogout(response1.getRefreshToken(), "password"); + + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response2 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken2 = oauth.verifyToken(response2.getAccessToken()); + + Assert.assertNotEquals(accessToken1.getId(), accessToken2.getId()); + Assert.assertNotEquals(accessToken1.getSessionId(), accessToken2.getSessionId()); + } + private void setupIdentityFirstFlow() { String newFlowAlias = "browser - identity first"; testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java+3 −20 modified@@ -146,30 +146,14 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); Assert.assertFalse(loginPage.isCurrent()); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); - // POST logout with token should fail - try (CloseableHttpResponse response = oauth.doLogout(refreshToken1, "password")) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - - String logoutUrl = oauth.getLogoutUrl() - .idTokenHint(accessTokenResponse.getIdToken()) - .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) - .build(); - - // GET logout with ID token should fail as well - try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); - CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - // finally POST logout with VALID token should succeed try (CloseableHttpResponse response = oauth.doLogout(tokenResponse2.getRefreshToken(), "password")) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT)); @@ -178,7 +162,6 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro } } - @Test public void postLogoutFailWithCredentialsOfDifferentClient() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -247,7 +230,7 @@ private void backchannelLogoutRequest(String expectedRefreshAlg, String expected .idTokenHint(idTokenString) .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) .build(); - + try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java+6 −6 modified@@ -1067,8 +1067,8 @@ public void refreshTokenAfterUserLogoutAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -1101,8 +1101,8 @@ public void refreshTokenAfterAdminLogoutAllAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -1134,8 +1134,8 @@ public void refreshTokenAfterUserAdminLogoutEndpointAndLoginAgain() { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java+2 −2 modified@@ -250,8 +250,8 @@ public void testIntrospectRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+9 −9 modified@@ -207,7 +207,7 @@ public void testSuccess_postMethod_body() throws Exception { client.close(); } } - + @Test public void testSuccess_postMethod_charset_body() throws Exception { Client client = AdminClientUtil.createResteasyClient(); @@ -531,7 +531,7 @@ public void testSuccessSignedResponsePS256() throws Exception { public void testSuccessSignedResponseRS256AcceptJWT() throws Exception { testSuccessSignedResponse(Algorithm.RS256, MediaType.APPLICATION_JWT); } - + @Test public void testSessionExpired() { Client client = AdminClientUtil.createResteasyClient(); @@ -607,8 +607,8 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent()); @@ -630,7 +630,7 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { response.close(); events.expect(EventType.USER_INFO_REQUEST_ERROR) - .error(Errors.INVALID_TOKEN) + .error(Errors.USER_SESSION_NOT_FOUND) .user(Matchers.nullValue(String.class)) .session(Matchers.nullValue(String.class)) .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) @@ -1088,23 +1088,23 @@ private void testRolesAreNotInUserInfoResponse(UserInfo userInfo) { assertNull(userInfo.getOtherClaims().get("realm_access")); assertNull(userInfo.getOtherClaims().get("resource_access")); } - + @Test public void test_noContentType() throws Exception { Client client = AdminClientUtil.createResteasyClient(); try { AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client); - + WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client); Response response = userInfoTarget.request() .header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getToken()) .build("POST") .invoke(); - + Assert.assertEquals(200, response.getStatus()); Assert.assertEquals("OK", response.getStatusInfo().toString()); - + } finally { client.close(); }
5344aada5ee0Remove root auth session after backchannel logout
8 files changed · +65 −40
services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java+1 −2 modified@@ -305,8 +305,7 @@ public static BackchannelLogoutResponse backchannelLogout(KeycloakSession sessio checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { logger.tracef("Removing logout session '%s' after backchannel logout", logoutAuthSession.getParentSession().getId()); - RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); - rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); + session.authenticationSessions().removeRootAuthenticationSession(realm, logoutAuthSession.getParentSession()); } userSession.setState(UserSessionModel.State.LOGGED_OUT);
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/BrowserTabUtil.java+5 −1 modified@@ -79,6 +79,10 @@ public static BrowserTabUtil getInstanceAndSetEnv(WebDriver driver) { return instance; } + public static void cleanup() { + instances = new ArrayList<>(); + } + public WebDriver getDriver() { return driver; } @@ -155,4 +159,4 @@ private void assertValidIndex(int index) { public void close() { destroy(); } -} \ No newline at end of file +}
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java+2 −0 modified@@ -56,6 +56,7 @@ import org.keycloak.testsuite.auth.page.login.UpdatePassword; import org.keycloak.testsuite.client.KeycloakTestingClient; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.CryptoInitRule; import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; @@ -251,6 +252,7 @@ public void afterAbstractKeycloakTest() throws Exception { // Remove all browsers from queue DroneUtils.resetQueue(); + BrowserTabUtil.cleanup(); } protected TestCleanup getCleanup(String realmName) {
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/ReAuthenticationTest.java+37 −0 modified@@ -49,6 +49,7 @@ import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; import org.keycloak.testsuite.pages.PasswordPage; +import org.keycloak.testsuite.util.BrowserTabUtil; import org.keycloak.testsuite.util.FederatedIdentityBuilder; import org.keycloak.testsuite.util.FlowUtil; import org.keycloak.testsuite.util.OAuthClient; @@ -366,6 +367,42 @@ public void loginAfterExpiredUserSession() { realmsResouce().realm(rep.getRealm()).update(rep); } + @Test + public void loginAfterLogoutWithDifferentSessionId() { + BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver); + + assertThat(tabUtil.getCountOfTabs(), Matchers.is(1)); + oauth.openLoginForm(); + loginPage.assertCurrent(); + + tabUtil.newTab(oauth.getLoginFormUrl()); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2)); + oauth.openLoginForm(); + + tabUtil.closeTab(tabUtil.getCountOfTabs() - 1); + assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); + + tabUtil.switchToTab(0); + loginPage.assertCurrent(); + + loginPage.login("test-user@localhost", "password"); + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response1 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken1 = oauth.verifyToken(response1.getAccessToken()); + + oauth.doLogout(response1.getRefreshToken(), "password"); + + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.login("test-user@localhost", "password"); + code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response2 = oauth.doAccessTokenRequest(code, "password"); + AccessToken accessToken2 = oauth.verifyToken(response2.getAccessToken()); + + Assert.assertNotEquals(accessToken1.getId(), accessToken2.getId()); + Assert.assertNotEquals(accessToken1.getSessionId(), accessToken2.getSessionId()); + } + private void setupIdentityFirstFlow() { String newFlowAlias = "browser - identity first"; testingClient.server("test").run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java+3 −20 modified@@ -146,30 +146,14 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); Assert.assertFalse(loginPage.isCurrent()); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); - // POST logout with token should fail - try (CloseableHttpResponse response = oauth.doLogout(refreshToken1, "password")) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - - String logoutUrl = oauth.getLogoutUrl() - .idTokenHint(accessTokenResponse.getIdToken()) - .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) - .build(); - - // GET logout with ID token should fail as well - try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); - CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { - assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); - } - // finally POST logout with VALID token should succeed try (CloseableHttpResponse response = oauth.doLogout(tokenResponse2.getRefreshToken(), "password")) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT)); @@ -178,7 +162,6 @@ public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro } } - @Test public void postLogoutFailWithCredentialsOfDifferentClient() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -247,7 +230,7 @@ private void backchannelLogoutRequest(String expectedRefreshAlg, String expected .idTokenHint(idTokenString) .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) .build(); - + try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { MatcherAssert.assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java+6 −6 modified@@ -942,8 +942,8 @@ public void refreshTokenAfterUserLogoutAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -976,8 +976,8 @@ public void refreshTokenAfterAdminLogoutAllAndLoginAgain() { try { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent()); @@ -1009,8 +1009,8 @@ public void refreshTokenAfterUserAdminLogoutEndpointAndLoginAgain() { // Continue with login setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java+2 −2 modified@@ -244,8 +244,8 @@ public void testIntrospectRefreshTokenAfterUserSessionLogoutAndLoginAgain() thro setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent());
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java+9 −9 modified@@ -207,7 +207,7 @@ public void testSuccess_postMethod_body() throws Exception { client.close(); } } - + @Test public void testSuccess_postMethod_charset_body() throws Exception { Client client = AdminClientUtil.createResteasyClient(); @@ -531,7 +531,7 @@ public void testSuccessSignedResponsePS256() throws Exception { public void testSuccessSignedResponseRS256AcceptJWT() throws Exception { testSuccessSignedResponse(Algorithm.RS256, MediaType.APPLICATION_JWT); } - + @Test public void testSessionExpired() { Client client = AdminClientUtil.createResteasyClient(); @@ -607,8 +607,8 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { setTimeOffset(2); - WaitUtils.waitForPageToLoad(); - loginPage.login("password"); + driver.navigate().refresh(); + oauth.fillLoginForm("test-user@localhost", "password"); events.expectLogin().assertEvent(); Assert.assertFalse(loginPage.isCurrent()); @@ -630,7 +630,7 @@ public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { response.close(); events.expect(EventType.USER_INFO_REQUEST_ERROR) - .error(Errors.INVALID_TOKEN) + .error(Errors.USER_SESSION_NOT_FOUND) .user(Matchers.nullValue(String.class)) .session(Matchers.nullValue(String.class)) .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) @@ -1088,23 +1088,23 @@ private void testRolesAreNotInUserInfoResponse(UserInfo userInfo) { assertNull(userInfo.getOtherClaims().get("realm_access")); assertNull(userInfo.getOtherClaims().get("resource_access")); } - + @Test public void test_noContentType() throws Exception { Client client = AdminClientUtil.createResteasyClient(); try { AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client); - + WebTarget userInfoTarget = UserInfoClientUtil.getUserInfoWebTarget(client); Response response = userInfoTarget.request() .header(HttpHeaders.AUTHORIZATION, "bearer " + accessTokenResponse.getToken()) .build("POST") .invoke(); - + Assert.assertEquals(200, response.getStatus()); Assert.assertEquals("OK", response.getStatusInfo().toString()); - + } finally { client.close(); }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
14- github.com/advisories/GHSA-rg35-5v25-mqvpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-12390ghsaADVISORY
- access.redhat.com/errata/RHSA-2025:21370nvdWEB
- access.redhat.com/errata/RHSA-2025:21371nvdWEB
- access.redhat.com/errata/RHSA-2025:22088nvdWEB
- access.redhat.com/errata/RHSA-2025:22089nvdWEB
- access.redhat.com/security/cve/CVE-2025-12390nvdWEB
- bugzilla.redhat.com/show_bug.cginvdWEB
- github.com/keycloak/keycloak/commit/5344aada5ee06b02ec3a9e0f52fa381d085b6282ghsaWEB
- github.com/keycloak/keycloak/commit/b46fab230824a2304daafe74be019e8bd4ee590aghsaWEB
- github.com/keycloak/keycloak/commit/d82438a611f2f869f1966c13012953fe963a493dghsaWEB
- github.com/keycloak/keycloak/commit/ef75a4dc50aa9459777494e4b88655100bf2ac80ghsaWEB
- github.com/keycloak/keycloak/issues/32197ghsaWEB
- github.com/keycloak/keycloak/issues/43853nvdWEB
News mentions
0No linked articles in our index yet.