Keycloak user enumeration via identity-first login

Closes #47619

Signed-off-by: vramik <vramik@redhat.com>
This commit is contained in:
Vlasta Ramik
2026-04-08 21:50:05 +02:00
committed by GitHub
parent 178b4ea09e
commit b4558a874f
8 changed files with 122 additions and 10 deletions
+3
View File
@@ -2,10 +2,12 @@ import { expect, test } from "@playwright/test";
import groupsRealm from "./realms/groups-realm.json" with { type: "json" };
import { login } from "./support/actions.ts";
import { createTestBed } from "./support/testbed.ts";
import { waitForRealmReady } from "./support/test-utils.ts";
test.describe("Groups", () => {
test("lists groups", async ({ page }) => {
await using testBed = await createTestBed(groupsRealm);
await waitForRealmReady();
await login(page, testBed.realm);
await page.getByTestId("groups").click();
@@ -14,6 +16,7 @@ test.describe("Groups", () => {
test("lists direct and indirect groups", async ({ page }) => {
await using testBed = await createTestBed(groupsRealm);
await waitForRealmReady();
await login(page, testBed.realm, "alice", "alice");
await page.getByTestId("groups").click();
@@ -261,11 +261,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
}
protected String getDefaultChallengeMessage(AuthenticationFlowContext context) {
if (isUserAlreadySetBeforeUsernamePasswordAuth(context)) {
return Messages.INVALID_PASSWORD;
} else {
return Messages.INVALID_USER;
}
return Messages.INVALID_USER;
}
protected boolean isUserAlreadySetBeforeUsernamePasswordAuth(AuthenticationFlowContext context) {
@@ -48,6 +48,14 @@ public class PasswordPage extends AbstractLoginPage {
return passwordInput.getAttribute("value");
}
public String getPasswordError() {
try {
return passwordError.getText();
} catch (NoSuchElementException e) {
return null;
}
}
public String getError() {
try {
return loginErrorMessage.getText();
@@ -0,0 +1,105 @@
/*
* Copyright 2025 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.tests.login;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
import org.keycloak.testframework.ui.annotations.InjectPage;
import org.keycloak.testframework.ui.page.LoginUsernamePage;
import org.keycloak.testframework.ui.page.PasswordPage;
import org.keycloak.tests.common.BasicUserConfig;
import org.keycloak.testsuite.util.FlowUtil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Verifies that login error messages do not leak whether a username is valid,
* preventing user enumeration attacks.
*
* In an identity-first flow (UsernameForm → UsernamePasswordForm), after the
* username step resolves the user, UsernamePasswordForm runs with
* USER_SET_BEFORE_USERNAME_PASSWORD_AUTH=true. A wrong password must show
* a generic "Invalid username or password." error, not "Invalid password."
* which would confirm the user exists.
*/
@KeycloakIntegrationTest
public class LoginErrorMessageTest {
private static final String IDENTITY_FIRST_FLOW = "identity-first-browser";
@InjectRealm(lifecycle = LifeCycle.METHOD)
ManagedRealm realm;
@InjectUser(config = BasicUserConfig.class)
ManagedUser user;
@InjectOAuthClient
OAuthClient oauth;
@InjectPage
LoginUsernamePage usernamePage;
@InjectPage
PasswordPage passwordPage;
@InjectRunOnServer
RunOnServerClient runOnServer;
@Test
public void testWrongPasswordShowsGenericErrorWhenUserPreEstablished() {
configureIdentityFirstFlow();
// Step 1: enter valid username on the username-only page
oauth.openLoginForm();
usernamePage.assertCurrent();
usernamePage.fillLoginWithUsernameOnly("basic-user");
usernamePage.submit();
// Step 2: UsernamePasswordForm renders with username hidden (user was pre-set).
// Use PasswordPage to interact with the password-only form.
passwordPage.fillPassword("wrong-password");
passwordPage.submit();
// The error must be generic "Invalid username or password." — not
// "Invalid password." which would confirm the username is valid.
assertEquals("Invalid username or password.", passwordPage.getPasswordError());
}
private void configureIdentityFirstFlow() {
runOnServer.run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(IDENTITY_FIRST_FLOW));
runOnServer.run(session -> FlowUtil.inCurrentRealm(session)
.selectFlow(IDENTITY_FIRST_FLOW)
.inForms(forms -> forms
.clear()
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, "auth-username-form")
.addAuthenticatorExecution(AuthenticationExecutionModel.Requirement.REQUIRED, "auth-username-password-form")
)
.defineAsBrowserFlow()
);
}
}
@@ -149,7 +149,7 @@ public class ReAuthenticationTest extends AbstractChangeImportedUserPasswordsTes
// Try bad password and assert things still hidden
loginPage.login("bad-password");
loginPage.assertCurrent();
Assert.assertEquals("Invalid password.", loginPage.getInputError());
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
assertUsernameFieldAndOtherFields(false);
assertInfoMessageAboutReAuthenticate(false);
@@ -273,7 +273,7 @@ public class OrganizationAuthenticationTest extends AbstractOrganizationTest {
loginPage.loginUsername(duplicatedUser.getEmail());
loginPage.clickSignIn();
loginPage.login(duplicatedUser.getEmail());
assertThat(loginPage.getInputError(), is("Invalid password."));
assertThat(loginPage.getInputError(), is("Invalid username or password."));
// trying to authenticate to the account that has the email as username is ok
oauth.loginForm().open();
@@ -215,7 +215,7 @@ public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirt
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
loginPage.login("invalid-password");
loginPage.assertCurrent();
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid username or password."));
events.expect(EventType.LOGIN_ERROR)
.error(Errors.INVALID_USER_CREDENTIALS)
.user(user.getId())
@@ -363,7 +363,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
// incorrect password (password of different user)
loginPage.login(getPassword("test-user@localhost"));
Assert.assertEquals("Invalid password.", loginPage.getInputError());
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
// Check that passkeys elements still available for this user
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
@@ -390,7 +390,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
// incorrect password (password of different user)
loginPage.login(getPassword("test-user@localhost"));
Assert.assertEquals("Invalid password.", loginPage.getInputError());
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
events.clear();