From 90c362cb61f182746d577bc45914b3e273f8abfa Mon Sep 17 00:00:00 2001 From: Marie Daly Date: Wed, 22 Jul 2026 13:58:35 +0100 Subject: [PATCH] Closes #50903, added length check for username Signed-off-by: Marie Daly --- .../IdpCreateUserIfUniqueAuthenticator.java | 8 + .../AbstractUsernameFormAuthenticator.java | 7 + .../directgrant/ValidateUsername.java | 20 +- .../resetcred/ResetCredentialChooseUser.java | 9 + .../util/AuthenticatorUtils.java | 13 ++ .../browser/OrganizationAuthenticator.java | 14 ++ .../services/validation/Validation.java | 1 + ...DeclarativeUserProfileProviderFactory.java | 21 +- ...dpCreateUserIfUniqueAuthenticatorTest.java | 180 ++++++++++++++++++ .../org/keycloak/tests/forms/LoginTest.java | 55 ++++++ .../tests/forms/ResetPasswordTest.java | 37 ++++ .../oauth/DirectGrantInputValidationTest.java | 87 +++++++++ .../OrganizationAuthenticationTest.java | 26 +++ 13 files changed, 473 insertions(+), 5 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/broker/IdpCreateUserIfUniqueAuthenticatorTest.java create mode 100644 tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpCreateUserIfUniqueAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpCreateUserIfUniqueAuthenticator.java index 9362f7af3702..0fdd550c19c4 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpCreateUserIfUniqueAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpCreateUserIfUniqueAuthenticator.java @@ -25,6 +25,7 @@ import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.authenticators.broker.util.ExistingUserInfo; import org.keycloak.authentication.authenticators.broker.util.SerializedBrokeredIdentityContext; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -73,6 +74,13 @@ protected void authenticateImpl(AuthenticationFlowContext context, SerializedBro return; } + if (AuthenticatorUtils.isUsernameTooLong(username)) { + ServicesLogger.LOGGER.resetFlow("Username exceeds maximum length"); + context.getAuthenticationSession().setAuthNote(ENFORCE_UPDATE_PROFILE, "true"); + context.resetFlow(); + return; + } + IdentityProviderModel broker = brokerContext.getIdpConfig(); ExistingUserInfo duplication = broker.isTransientUsers() ? null : checkExistingUser(context, username, serializedCtx, brokerContext); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java index 2050169ef2bb..a8047d2d5f4d 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java @@ -173,6 +173,13 @@ private UserModel getUserFromForm(AuthenticationFlowContext context, Multivalued // remove leading and trailing whitespace username = username.trim(); + if (AuthenticatorUtils.isUsernameTooLong(username)) { + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return null; + } + context.getEvent().detail(Details.USERNAME, username); context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java index 510d94e6060e..c60fea838911 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java @@ -54,12 +54,28 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator { @Override public void authenticate(AuthenticationFlowContext context) { String username = retrieveUsername(context); - if (username == null) { + + if (username != null) { + username = username.trim(); + } + + if (username == null || username.isEmpty()) { context.getEvent().error(Errors.USER_NOT_FOUND); Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Missing parameter: username"); context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); return; } + + if (AuthenticatorUtils.isUsernameTooLong(username)) { + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = errorResponse( + Response.Status.BAD_REQUEST.getStatusCode(), + "invalid_grant", + "Invalid user credentials"); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + context.getEvent().detail(Details.USERNAME, username); context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username); @@ -175,7 +191,7 @@ public List getConfigProperties() { public String getId() { return PROVIDER_ID; } - + protected String retrieveUsername(AuthenticationFlowContext context) { MultivaluedMap inputData = context.getHttpRequest().getDecodedFormParameters(); return inputData.getFirst(AuthenticationManager.FORM_USERNAME); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java index 2a81f9444247..2c0c3f5c29cd 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java @@ -29,6 +29,7 @@ import org.keycloak.authentication.AuthenticatorFactory; import org.keycloak.authentication.authenticators.broker.AbstractIdpAuthenticator; import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; @@ -110,6 +111,14 @@ public void action(AuthenticationFlowContext context) { } username = username.trim(); + if (AuthenticatorUtils.isUsernameTooLong(username)) { + event.error(Errors.USER_NOT_FOUND); + Response challengeResponse = context.form() + .addError(new FormMessage(Validation.FIELD_USERNAME, Messages.INVALID_USER)) + .createPasswordReset(); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } RealmModel realm = context.getRealm(); UserModel user = context.getSession().users().getUserByUsername(realm, username); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java index 4e2d6802e38e..b475a68b3e54 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java @@ -35,6 +35,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.services.validation.Validation; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.JsonSerialization; @@ -84,6 +85,18 @@ public static void dummyHash(AuthenticationFlowContext context) { provider.encodedCredential("SlightlyLongerDummyPassword", iterations); } + /** + * Returns {@code true} if {@code username} exceeds the maximum length allowed by the + * USER_ENTITY table column (VARCHAR(255)). Callers should invoke {@link #dummyHash(AuthenticationFlowContext)} + * and return their appropriate failure response when this returns {@code true}. + * + * @param username the trimmed username string to test; may be {@code null} + * @return true if the username is too long to be stored + */ + public static boolean isUsernameTooLong(String username) { + return username != null && username.length() > Validation.MAX_USERNAME_LENGTH; + } + /** * Get all completed authenticator executions from the user session notes. * @param note The serialized note value to parse diff --git a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java index 15d116165494..fb3051669b38 100644 --- a/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java +++ b/services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java @@ -36,6 +36,7 @@ import org.keycloak.authentication.authenticators.browser.WebAuthnConditionalUIAuthenticator; import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; import org.keycloak.email.freemarker.beans.ProfileBean; +import org.keycloak.events.Errors; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.forms.login.freemarker.model.AuthenticationContextBean; import org.keycloak.forms.login.freemarker.model.IdentityProviderBean; @@ -132,6 +133,10 @@ public void action(AuthenticationFlowContext context) { UserModel user = context.getUser(); + if (username != null) { + username = username.trim(); + } + if (user == null && isBlank(username)) { initialChallenge(context, form -> { form.addError(new FormMessage(UserModel.USERNAME, Messages.INVALID_USERNAME)); @@ -140,6 +145,15 @@ public void action(AuthenticationFlowContext context) { return; } + if (AuthenticatorUtils.isUsernameTooLong(username)) { + context.getEvent().error(Errors.USER_NOT_FOUND); + Response challengeResponse = context.form() + .addError(new FormMessage(UserModel.USERNAME, Messages.INVALID_USERNAME)) + .createLoginUsername(); + context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse); + return; + } + action(context, username); } diff --git a/services/src/main/java/org/keycloak/services/validation/Validation.java b/services/src/main/java/org/keycloak/services/validation/Validation.java index 527c0544076d..beb4b0293092 100755 --- a/services/src/main/java/org/keycloak/services/validation/Validation.java +++ b/services/src/main/java/org/keycloak/services/validation/Validation.java @@ -33,6 +33,7 @@ public class Validation { public static final String FIELD_USERNAME = "username"; public static final String FIELD_OTP_CODE = "totp"; public static final String FIELD_OTP_LABEL = "userLabel"; + public static final int MAX_USERNAME_LENGTH = 255; // USER_ENTITY table private static final Pattern USERNAME_PATTERN = Pattern.compile("^[\\p{IsLatin}|\\p{IsCommon}]+$"); diff --git a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java index f975ed465bf9..81e8d9a36610 100644 --- a/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java @@ -70,12 +70,14 @@ import org.keycloak.utils.StringUtil; import org.keycloak.validate.ValidatorConfig; import org.keycloak.validate.validators.EmailValidator; +import org.keycloak.validate.validators.LengthValidator; import org.jspecify.annotations.NonNull; import static java.util.Optional.ofNullable; import static org.keycloak.common.util.ObjectUtil.isBlank; +import static org.keycloak.services.validation.Validation.MAX_USERNAME_LENGTH; import static org.keycloak.userprofile.DefaultAttributes.READ_ONLY_ATTRIBUTE_KEY; import static org.keycloak.userprofile.UserProfileContext.ACCOUNT; import static org.keycloak.userprofile.UserProfileContext.IDP_REVIEW; @@ -424,7 +426,9 @@ private UserProfileMetadata createBrokeringProfile(AttributeValidatorMetadata re UserProfileMetadata metadata = new UserProfileMetadata(IDP_REVIEW); metadata.addAttribute(UserModel.USERNAME, -2, DeclarativeUserProfileProviderFactory::editUsernameCondition, - DeclarativeUserProfileProviderFactory::readUsernameCondition, new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID)).setAttributeDisplayName("${username}"); + DeclarativeUserProfileProviderFactory::readUsernameCondition, + new AttributeValidatorMetadata(BrokeringFederatedUsernameHasValueValidator.ID), + createUsernameLengthValidator()).setAttributeDisplayName("${username}"); metadata.addAttribute(UserModel.EMAIL, -1, new AttributeValidatorMetadata(BlankAttributeValidator.ID, BlankAttributeValidator.createConfig(Messages.MISSING_EMAIL, true))) @@ -466,7 +470,8 @@ private UserProfileMetadata createDefaultProfile(UserProfileContext context, Att DeclarativeUserProfileProviderFactory::readUsernameCondition, new AttributeValidatorMetadata(UsernameHasValueValidator.ID), new AttributeValidatorMetadata(DuplicateUsernameValidator.ID), - new AttributeValidatorMetadata(UsernameMutationValidator.ID)).setAttributeDisplayName("${username}"); + new AttributeValidatorMetadata(UsernameMutationValidator.ID), + createUsernameLengthValidator()).setAttributeDisplayName("${username}"); metadata.addAttribute(UserModel.EMAIL, -1, DeclarativeUserProfileProviderFactory::editEmailCondition, @@ -498,7 +503,8 @@ private UserProfileMetadata createUserResourceValidation(Config.Scope config) { metadata.addAttribute(UserModel.USERNAME, -2, new AttributeValidatorMetadata(UsernameHasValueValidator.ID), - new AttributeValidatorMetadata(DuplicateUsernameValidator.ID)) + new AttributeValidatorMetadata(DuplicateUsernameValidator.ID), + createUsernameLengthValidator()) .addWriteCondition(DeclarativeUserProfileProviderFactory::editUsernameCondition); metadata.addAttribute(UserModel.EMAIL, -1, new AttributeValidatorMetadata(DuplicateEmailValidator.ID), @@ -612,4 +618,13 @@ private boolean isUpdateEmailFeatureEnabled(AttributeContext context) { return UpdateEmail.isEnabled(realm); } + + private AttributeValidatorMetadata createUsernameLengthValidator() { + // IDP_REVIEW (brokering) profile has no UsernameHasValueValidator, provides the lower-bound guard. + return new AttributeValidatorMetadata(LengthValidator.ID, + ValidatorConfig.builder() + .config(LengthValidator.KEY_MIN, "1") + .config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH)) + .build()); + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/broker/IdpCreateUserIfUniqueAuthenticatorTest.java b/tests/base/src/test/java/org/keycloak/tests/broker/IdpCreateUserIfUniqueAuthenticatorTest.java new file mode 100644 index 000000000000..6984fe7d5f4f --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/broker/IdpCreateUserIfUniqueAuthenticatorTest.java @@ -0,0 +1,180 @@ +package org.keycloak.tests.broker; + +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; +import org.keycloak.broker.oidc.mappers.UsernameTemplateMapper; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; +import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.validation.Validation; +import org.keycloak.testframework.annotations.InjectRealm; +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.ClientBuilder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmBuilder; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.UserBuilder; +import org.keycloak.testframework.ui.annotations.InjectPage; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testframework.ui.page.LoginPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import org.keycloak.testframework.util.ApiUtil; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for {@link org.keycloak.authentication.authenticators.broker.IdpCreateUserIfUniqueAuthenticator}. + */ +@KeycloakIntegrationTest +public class IdpCreateUserIfUniqueAuthenticatorTest { + + private static final String IDP_ALIAS = "create-user-idp"; + private static final String CLIENT_ID = "broker-client"; + private static final String CLIENT_SECRET = "broker-secret"; + private static final String PROVIDER_USERNAME = "provider-user"; + + @InjectRealm(ref = "provider", config = ProviderRealmConfig.class, lifecycle = LifeCycle.CLASS) + ManagedRealm providerRealm; + + @InjectRealm(ref = "consumer", config = ConsumerRealmConfig.class, lifecycle = LifeCycle.CLASS) + ManagedRealm consumerRealm; + + @InjectOAuthClient(realmRef = "consumer") + OAuthClient oauth; + + @InjectWebDriver + ManagedWebDriver driver; + + @InjectPage + LoginPage loginPage; + + @Test // #50903 + public void testIdpBrokerLoginRejectsUsernameLongerThanMaxLength() { + // Use a mapper to inject username that is too long, don't persist length in the db. + String oversizedUsername = "a".repeat(Validation.MAX_USERNAME_LENGTH + 1); + addUsernameTemplateMapper("oversized-username-mapper", oversizedUsername); + + createProviderUser(); + oauth.openLoginForm(); + loginPage.clickSocial(IDP_ALIAS); + + assertTrue(driver.getCurrentUrl().contains("/realms/" + providerRealm.getName() + "/"), + "Expected redirect to provider realm login page"); + + loginPage.fillLogin(PROVIDER_USERNAME, "password"); + loginPage.submit(); + assertTrue("login-idp-review-user-profile".equals(driver.page().getCurrentPageId()), + "Expected broker flow to restart at IDP review step after resetFlow(), but page was: " + + driver.page().getCurrentPageId()); + consumerRealm.admin().users().search(oversizedUsername).forEach(u -> + consumerRealm.cleanup().add(r -> r.users().get(u.getId()).remove())); + List created = consumerRealm.admin().users().search(oversizedUsername); + assertTrue(created.isEmpty(), "No user with an oversized username should be created in the consumer realm"); + } + + @Test // #50903 + public void testIdpBrokerLoginAcceptsUsernameAtMaxLength() { + String boundaryUsername = "a".repeat(Validation.MAX_USERNAME_LENGTH); + addUsernameTemplateMapper("boundary-username-mapper", boundaryUsername); + + createProviderUser(); + oauth.openLoginForm(); + loginPage.clickSocial(IDP_ALIAS); + + assertTrue(driver.getCurrentUrl().contains("/realms/" + providerRealm.getName() + "/"), + "Expected redirect to provider realm login page"); + + loginPage.fillLogin(PROVIDER_USERNAME, "password"); + loginPage.submit(); + + assertFalse("login-idp-review-user-profile".equals(driver.page().getCurrentPageId()), + "Boundary-length username must not trigger the length guard resetFlow()"); + + List created = consumerRealm.admin().users().search(boundaryUsername, true); + assertFalse(created.isEmpty(), + "A user with a boundary-length username must be created in the consumer realm"); + created.forEach(u -> consumerRealm.cleanup().add(r -> r.users().get(u.getId()).remove())); + } + + private void addUsernameTemplateMapper(String mapperName, String usernameTemplate) { + // adds to consumer IDP, replaces brokered username with mapperName + IdentityProviderMapperRepresentation mapper = new IdentityProviderMapperRepresentation(); + mapper.setName(mapperName); + mapper.setIdentityProviderAlias(IDP_ALIAS); + mapper.setIdentityProviderMapper(UsernameTemplateMapper.PROVIDER_ID); + mapper.setConfig(Map.of( + UsernameTemplateMapper.TEMPLATE, usernameTemplate, + "target", "LOCAL" + )); + try (Response response = consumerRealm.admin().identityProviders().get(IDP_ALIAS).addMapper(mapper)) { + Assertions.assertEquals(201, response.getStatus()); + String mapperId = ApiUtil.getCreatedId(response); + consumerRealm.cleanup().add(r -> r.identityProviders().get(IDP_ALIAS).delete(mapperId)); + } + } + + private void createProviderUser() { + UserRepresentation user = UserBuilder.create(PROVIDER_USERNAME) + .password("password") + .email(PROVIDER_USERNAME + "@example.com") + .emailVerified(true) + .firstName("Provider") + .lastName("User") + .build(); + try (Response response = providerRealm.admin().users().create(user)) { + Assertions.assertEquals(201, response.getStatus()); + String userId = ApiUtil.getCreatedId(response); + providerRealm.cleanup().add(r -> r.users().get(userId).remove()); + } + } + + public static class ProviderRealmConfig implements RealmConfig { + + @Override + public RealmBuilder configure(RealmBuilder realm) { + return realm.clients( + ClientBuilder.create(CLIENT_ID) + .secret(CLIENT_SECRET) + .redirectUris("*") + .directAccessGrantsEnabled() + ); + } + } + + public static class ConsumerRealmConfig implements RealmConfig { + + @Override + public RealmBuilder configure(RealmBuilder realm) { + String providerBase = "http://localhost:8080/realms/provider"; + + IdentityProviderRepresentation idp = new IdentityProviderRepresentation(); + idp.setAlias(IDP_ALIAS); + idp.setProviderId(OIDCIdentityProviderFactory.PROVIDER_ID); + idp.setEnabled(true); + idp.setTrustEmail(true); + idp.setConfig(Map.of( + "clientId", CLIENT_ID, + "clientSecret", CLIENT_SECRET, + "authorizationUrl", providerBase + "/protocol/openid-connect/auth", + "tokenUrl", providerBase + "/protocol/openid-connect/token", + "userInfoUrl", providerBase + "/protocol/openid-connect/userinfo", + "jwksUrl", providerBase + "/protocol/openid-connect/certs", + "defaultScope", "openid email profile", + IdentityProviderModel.SYNC_MODE, "IMPORT" + )); + return realm.identityProviders(idp); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/forms/LoginTest.java b/tests/base/src/test/java/org/keycloak/tests/forms/LoginTest.java index 85e66a692a19..91b121841007 100644 --- a/tests/base/src/test/java/org/keycloak/tests/forms/LoginTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/forms/LoginTest.java @@ -40,6 +40,7 @@ import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.validation.Validation; import org.keycloak.sessions.RootAuthenticationSessionModel; import org.keycloak.testframework.annotations.InjectEvents; import org.keycloak.testframework.annotations.InjectHttpClient; @@ -1147,6 +1148,60 @@ public void testUserDisabledDuringRequiredAction() { assertThat(errorPage.getError(), containsString("Account is disabled")); } + @Test + public void loginMaxLengthUsername() { + oauth.openLoginForm(); + loginPage.fillLogin("a".repeat(Validation.MAX_USERNAME_LENGTH + 1), "invalid"); + loginPage.submit(); + + loginPage.assertCurrent(); + + assertEquals("Invalid username or password.", loginPage.getUsernameInputError()); + + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .userId(null) + .sessionId(null) + .error(Errors.USER_NOT_FOUND) + .withoutDetails(Details.USERNAME); + } + + @Test + public void loginExactMaxLengthUsername() { + oauth.openLoginForm(); + loginPage.fillLogin("a".repeat(Validation.MAX_USERNAME_LENGTH), "invalid"); + loginPage.submit(); + + loginPage.assertCurrent(); + + assertEquals("Invalid username or password.", loginPage.getUsernameInputError()); + + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .userId(null) + .sessionId(null) + .error(Errors.USER_NOT_FOUND) + .details(Details.USERNAME, "a".repeat(Validation.MAX_USERNAME_LENGTH)); + } + + @Test + public void loginWhitespaceOnlyUsername() { + oauth.openLoginForm(); + loginPage.fillLogin(" ", "invalid"); + loginPage.submit(); + + loginPage.assertCurrent(); + + assertEquals("Invalid username or password.", loginPage.getUsernameInputError()); + + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .userId(null) + .sessionId(null) + .error(Errors.USER_NOT_FOUND) + .withoutDetails(Details.USERNAME); + } + static class DynamicScopeServerConfig implements KeycloakServerConfig { @Override public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) { diff --git a/tests/base/src/test/java/org/keycloak/tests/forms/ResetPasswordTest.java b/tests/base/src/test/java/org/keycloak/tests/forms/ResetPasswordTest.java index 9572aa33cefb..6091c14473d2 100644 --- a/tests/base/src/test/java/org/keycloak/tests/forms/ResetPasswordTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/forms/ResetPasswordTest.java @@ -6,11 +6,13 @@ import org.keycloak.broker.oidc.OIDCIdentityProviderFactory; import org.keycloak.events.Details; +import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; +import org.keycloak.services.validation.Validation; import org.keycloak.testframework.annotations.InjectEvents; import org.keycloak.testframework.annotations.InjectRealm; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -208,6 +210,41 @@ public void resetPasswordEmailLinkWorksAfterNavigatingBackToLoginPage() throws I assertTrue(driver.page().getPageSource().contains("Happy days")); } + @Test + public void resetPasswordMaxLengthUsername() { + + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.resetPassword(); + resetPasswordPage.assertCurrent(); + resetPasswordPage.changePassword("a".repeat(Validation.MAX_USERNAME_LENGTH + 1)); + resetPasswordPage.assertCurrent(); + + EventAssertion.assertError(events.poll()) + .type(EventType.RESET_PASSWORD_ERROR) + .userId(null) + .sessionId(null) + .error(Errors.USER_NOT_FOUND) + .withoutDetails(Details.USERNAME); + } + + @Test + public void resetPasswordExactMaxLengthUsername() { + oauth.openLoginForm(); + loginPage.assertCurrent(); + loginPage.resetPassword(); + resetPasswordPage.assertCurrent(); + resetPasswordPage.changePassword("a".repeat(Validation.MAX_USERNAME_LENGTH)); + loginPage.assertCurrent(); + + EventAssertion.assertError(events.poll()) + .type(EventType.RESET_PASSWORD_ERROR) + .userId(null) + .sessionId(null) + .error(Errors.USER_NOT_FOUND) + .details(Details.USERNAME, "a".repeat(Validation.MAX_USERNAME_LENGTH)); + } + static class ConsumerRealmConfig implements RealmConfig { @Override public RealmBuilder configure(RealmBuilder realm) { diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java new file mode 100644 index 000000000000..3e53fc38aaf9 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java @@ -0,0 +1,87 @@ +package org.keycloak.tests.oauth; + +import org.keycloak.services.validation.Validation; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.InjectUser; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +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.realm.UserBuilder; +import org.keycloak.testframework.realm.UserConfig; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@KeycloakIntegrationTest +public class DirectGrantInputValidationTest { + + @InjectRealm + ManagedRealm realm; + + @InjectOAuthClient + OAuthClient oauth; + + @InjectUser(config = DirectGrantUserConfig.class) + ManagedUser user; + + @Test + public void directGrantRejectsUsernameLongerThanMaxLength() { + AccessTokenResponse response = doGrant("a".repeat(Validation.MAX_USERNAME_LENGTH + 1), "password"); + assertEquals(400, response.getStatusCode()); + assertEquals("invalid_grant", response.getError()); + assertNull(response.getAccessToken()); + } + + @Test + public void directGrantAcceptsUsernameLengthAtMaxLength() { + // A username exactly at the limit passes the length check; the user doesn't exist so we get + // the standard user-not-found error rather than the over-length rejection. + AccessTokenResponse response = doGrant("a".repeat(Validation.MAX_USERNAME_LENGTH), "password"); + assertEquals(400, response.getStatusCode()); + assertEquals("invalid_grant", response.getError()); + } + + @Test + public void directGrantRejectsWhitespaceOnlyUsernameAsMissing() { + AccessTokenResponse response = doGrant(" ", "password"); + assertEquals(401, response.getStatusCode()); + assertEquals("invalid_request", response.getError()); + assertEquals("Missing parameter: username", response.getErrorDescription()); + assertNull(response.getAccessToken()); + } + + @Test + public void directGrantAcceptsUsernameWithSurroundingWhitespace() { + AccessTokenResponse response = doGrant(" validuser ", "password"); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getAccessToken()); + } + + @Test + public void directGrantAcceptsUsernameHappyPath() { + AccessTokenResponse response = doGrant("validuser", "password"); + assertEquals(200, response.getStatusCode()); + assertNotNull(response.getAccessToken()); + } + + private AccessTokenResponse doGrant(String username, String password) { + return oauth.doPasswordGrantRequest(username, password); + } + + public static class DirectGrantUserConfig implements UserConfig { + @Override + public UserBuilder configure(UserBuilder user) { + return user.username("validuser") + .password("password") + .email("validuser@localhost") + .name("Valid", "User") + .emailVerified(true); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/organization/authentication/OrganizationAuthenticationTest.java b/tests/base/src/test/java/org/keycloak/tests/organization/authentication/OrganizationAuthenticationTest.java index 368fe4ed6e93..62d379a226a7 100644 --- a/tests/base/src/test/java/org/keycloak/tests/organization/authentication/OrganizationAuthenticationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/organization/authentication/OrganizationAuthenticationTest.java @@ -37,6 +37,7 @@ import org.keycloak.representations.idm.OrganizationRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.validation.Validation; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.oauth.OAuthClient; import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; @@ -144,6 +145,31 @@ public void testEmptyUserNameValidation() { assertEquals("Invalid username.", loginUsernamePage.getUsernameInputError()); } + @Test + public void testMaxLengthUserNameValidation() { + createOrganization(); + + oauth.openLoginForm(); + assertFalse(loginPage.isPasswordInputPresent()); + loginUsernamePage.fillLoginWithUsernameOnly("a".repeat(Validation.MAX_USERNAME_LENGTH + 1)); + loginUsernamePage.submit(); + + assertEquals("Invalid username.", loginUsernamePage.getUsernameInputError()); + } + + @Test + public void testExactMaxLengthUserNameValidation() { + createOrganization(); + + oauth.openLoginForm(); + assertFalse(loginPage.isPasswordInputPresent()); + loginUsernamePage.fillLoginWithUsernameOnly("a".repeat(Validation.MAX_USERNAME_LENGTH)); + loginUsernamePage.submit(); + + assertNull(loginUsernamePage.getUsernameInputError()); + assertTrue(loginPage.isPasswordInputPresent()); + } + @Test public void testDefaultAuthenticationMechanismIfNotOrganizationMember() { realm.admin().organizations().get(createOrganization().getId());