Added length check for username - #51129
Conversation
rmartinc
left a comment
There was a problem hiding this comment.
Thanks @msdaly200! LGTM but please create a simple test (for example in LoginTests.java) that ensures the username detail is empty in the event. Something like:
@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);
}41be37e to
2747b61
Compare
|
Thanks @rmartinc I've added tests. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a max-length constraint (255 chars) to username handling across multiple authentication flows to prevent overly long usernames from being processed, and introduces regression tests for key user-facing paths.
Changes:
- Introduced
Validation.MAX_USERNAME_LENGTH = 255and applied it during username processing. - Added max-length validation in browser login (including organization login), reset password, and direct grant username validation.
- Added/extended integration tests to cover max-length and whitespace-only username cases in UI flows.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/base/src/test/java/org/keycloak/tests/organization/authentication/OrganizationAuthenticationTest.java | Adds a new test asserting org login rejects usernames longer than the max length. |
| tests/base/src/test/java/org/keycloak/tests/forms/ResetPasswordTest.java | Adds a reset-password test for overlong usernames and asserts expected event/error behavior. |
| tests/base/src/test/java/org/keycloak/tests/forms/LoginTest.java | Adds login tests for overlong and whitespace-only usernames (UI flow). |
| services/src/main/java/org/keycloak/services/validation/Validation.java | Introduces a shared MAX_USERNAME_LENGTH constant (255). |
| services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java | Trims username and rejects usernames exceeding max length in org login flow. |
| services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java | Trims username and rejects usernames exceeding max length in reset credential flow. |
| services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java | Trims username and rejects usernames exceeding max length in direct grant flow. |
| services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java | Rejects usernames exceeding max length in standard browser username form flow. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
services/src/main/java/org/keycloak/services/validation/Validation.java:36
- The inline comment is overly specific to a particular DDL/type (
NVARCHAR(255)), which may be inaccurate across DB vendors and can become stale if schema definitions change. Consider making the comment DB-agnostic (e.g., referencing the username column length limit) or linking to the authoritative schema/migration source that defines the 255 limit.
public static final int MAX_USERNAME_LENGTH = 255; // USER_ENTITY table NVARCHAR(255)
services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java:182
- The same trim + max-length validation is now implemented in several authenticators (browser login, direct grant, reset password, organization flow). To reduce duplication and keep behavior consistent (including which error/response is returned), consider centralizing this into a small helper in
Validation(or a shared authenticator utility) that performs trim + length check and returns a normalized username (or a standardized failure outcome). This will make future changes to username constraints less error-prone.
// remove leading and trailing whitespace
username = username.trim();
if (username.length() > MAX_USERNAME_LENGTH) {
context.getEvent().error(Errors.USER_NOT_FOUND);
Response challengeResponse = challenge(context, getDefaultChallengeMessage(context), FIELD_USERNAME);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, challengeResponse);
return null;
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java:152
- A successful passkey submission sets
context.getUser()but does not include ausernamefield (the passkey form only posts WebAuthn fields), so this unconditional trim throws aNullPointerExceptionand breaks passkey login when the organization authenticator is enabled. Preserve the existing user-resolved path by only normalizing and validating a non-null submitted username.
// remove leading and trailing whitespace
username = username.trim();
if (username.length() > MAX_USERNAME_LENGTH) {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (4)
services/src/main/java/org/keycloak/authentication/authenticators/resetcred/ResetCredentialChooseUser.java:116
dummyHashperforms a full password hash, but this reset-credential step never hashes a password for either existing or missing users. Calling it only for over-length input adds an expensive unauthenticated CPU path without providing timing protection; reject the value directly as the surrounding reset flow does.
AuthenticatorUtils.dummyHash(context);
services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java:151
- This username-only organization step does not hash passwords for normal valid or unknown usernames, so
dummyHashmakes only the over-length case perform a full password hash. That exposes an unnecessary unauthenticated CPU-expensive path; reject the input without hashing here.
AuthenticatorUtils.dummyHash(context);
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:627
- This validator does not provide a non-overridable 255-character cap for realms with a custom user profile. The profile decorator removes built-in validators whose IDs occur in the system profile (including
length) and then applies the realm's validator, so omittinglengthfalls back to 2048 and configuring a larger maximum replaces this guard; use a dedicated non-configurable username-cap validator or explicitly cap the configured maximum.
return new AttributeValidatorMetadata(LengthValidator.ID,
ValidatorConfig.builder()
.config(LengthValidator.KEY_MIN, "1")
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
.build());
tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java:47
- This boundary test cannot prove that a 255-character username passed the new guard: both the normal user-not-found path and an erroneous
>= MAX_USERNAME_LENGTHrejection return the same 400invalid_grant. Exercise an existing boundary-length user (expect success) or assert the emitted username detail so the test fails if the guard rejects the boundary value.
AccessTokenResponse response = doGrant("a".repeat(Validation.MAX_USERNAME_LENGTH), "password");
assertEquals(400, response.getStatusCode());
assertEquals("invalid_grant", response.getError());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:627
LengthValidatortrims by default, while the user-profile persistence path preserves nonblank leading/trailing whitespace. A username containing 255 characters plus a long whitespace suffix therefore passes this check but is still written over theUSER_ENTITYlimit; disable trimming for this storage-length constraint.
ValidatorConfig.builder()
.config(LengthValidator.KEY_MIN, "1")
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
.build());
tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java:47
- This boundary test cannot detect an off-by-one regression because both the over-limit branch and the normal unknown-user path return the same
400/invalid_grant; changing the guard to>=would leave both tests green. Make the 255-character case observably reach user lookup, for example by granting successfully as an injected boundary-length user or asserting that the error event containsDetails.USERNAME.
AccessTokenResponse response = doGrant("a".repeat(Validation.MAX_USERNAME_LENGTH), "password");
assertEquals(400, response.getStatusCode());
assertEquals("invalid_grant", response.getError());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:628
LengthValidatortrims by default, but user-profile persistence does not trim usernames (DefaultAttributes.java:472-494). A raw username longer than 255 characters can therefore pass when the excess is surrounding whitespace and then reach theVARCHAR(255)column; disable validator trimming so the bound applies to the stored value.
return new AttributeValidatorMetadata(LengthValidator.ID,
ValidatorConfig.builder()
.config(LengthValidator.KEY_MIN, "1")
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
.build());
mabartos
left a comment
There was a problem hiding this comment.
@msdaly200 Thanks for the PR. I've just added a few comments, mainly for the dummyHash as Copilot suggested. Let me know what you think. Thanks!
mabartos
left a comment
There was a problem hiding this comment.
LGTM, just one thing related to tests
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
services/src/main/java/org/keycloak/organization/authentication/authenticators/browser/OrganizationAuthenticator.java:153
- This guard only validates the submitted form value.
authenticate()still passes the unboundedlogin_hintclient note directly toaction(context, loginHint), so an unauthenticated oversized username can bypass this check and reach organization/user lookup and flow state. Normalize and validate the hint through the same path before calling the private action.
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);
tests/base/src/test/java/org/keycloak/tests/broker/IdpCreateUserIfUniqueAuthenticatorTest.java:99
- This test does not exercise the new
IdpCreateUserIfUniqueAuthenticatorguard: the default first-broker-login flow runsidp-review-profilefirst, and the newly added IDP_REVIEW length validator already renders this same page for the oversized value. Configure review-profile not to validate/show before asserting the laterresetFlow(), otherwise removing the guard under test would still leave this test green.
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());
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:628
LengthValidatortrims by default, but user-profile persistence keeps the original username. Consequently, a raw value such as 255 characters plus a trailing space passes this 255-character check and can still exceed theUSER_ENTITYcolumn; disable trimming for this storage-bound validation.
return new AttributeValidatorMetadata(LengthValidator.ID,
ValidatorConfig.builder()
.config(LengthValidator.KEY_MIN, "1")
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
.build());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java:91
- This blanket
dummyHashrequirement is incorrect: reset-credential and broker callers do not verify passwords and intentionally reject without hashing to avoid unauthenticated CPU amplification. Document only the required failure response rather than instructing every caller to hash.
* 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}.
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:628
LengthValidatortrims by default, so a stored username such as 255 characters plus trailing whitespace passes this max check even though its actual value exceeds the 255-character column limit; in the broker flow it can also loop between review and the raw-length guard. Disable trimming for this storage-bound validation so the full persisted value is counted.
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
tests/base/src/test/java/org/keycloak/tests/oauth/DirectGrantInputValidationTest.java:47
- This boundary test cannot detect an off-by-one regression: both the new oversized guard and the normal unknown-user path return the same
400 invalid_grant, so it still passes if length 255 is rejected. Authenticate a real 255-character username or assert the emitted username detail to distinguish the paths.
AccessTokenResponse response = doGrant("a".repeat(Validation.MAX_USERNAME_LENGTH), "password");
assertEquals(400, response.getStatusCode());
assertEquals("invalid_grant", response.getError());
Signed-off-by: Marie Daly <marie.daly1@ibm.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
services/src/main/java/org/keycloak/userprofile/DeclarativeUserProfileProviderFactory.java:628
LengthValidatortrims before measuring, while user-profile normalization preserves the original username. A value such as 255 characters plus a very large trailing-space suffix therefore passes this validator and can still be sent to theVARCHAR(255)column; disable trimming for this storage bound.
ValidatorConfig.builder()
.config(LengthValidator.KEY_MIN, "1")
.config(LengthValidator.KEY_MAX, String.valueOf(MAX_USERNAME_LENGTH))
services/src/main/java/org/keycloak/authentication/authenticators/broker/IdpCreateUserIfUniqueAuthenticator.java:81
- This guard is bypassed by
IdpDetectExistingBrokerUserAuthenticator, which overridesauthenticateImplwithout calling this implementation. Its oversized username still reachescheckExistingUserand is logged/rendered verbatim atIdpDetectExistingBrokerUserAuthenticator.java:58-64, retaining the amplification path for realms using that first-login flow; move the guard into shared preprocessing or add it to the override.
if (AuthenticatorUtils.isUsernameTooLong(username)) {
ServicesLogger.LOGGER.resetFlow("Username exceeds maximum length");
context.getAuthenticationSession().setAuthNote(ENFORCE_UPDATE_PROFILE, "true");
context.resetFlow();
return;
services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java:91
- The new call sites all reject oversized input without
dummyHash, and the reset-credential path intentionally must not add a password hash to an unauthenticated request. Requiring every future caller to hash is therefore incorrect and risks CPU amplification; remove that mandate from the method contract.
* 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}.
rmartinc
left a comment
There was a problem hiding this comment.
Thanks @msdaly200 for the PR and @mabartos for the review!
Closes #50903
Added length check for username (255)