fix(username): only store valid displayUsername fallbacks as usernames (#10182)

This commit is contained in:
Taesu
2026-06-21 22:16:44 -07:00
committed by GitHub
parent 6e5f7e656b
commit 461ca6fd24
3 changed files with 115 additions and 53 deletions

View File

@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Only store display username fallbacks as usernames when they pass username validation during email sign-up.

View File

@@ -144,28 +144,38 @@ export const username = (options?: UsernameOptions | undefined) => {
const pathsWithHttpHookValidation = ["/sign-up/email", "/update-user"];
const getUsernameToValidate = (username: string) =>
options?.validationOrder?.username === "post-normalization"
? normalizer(username)
: username;
async function validateUsernameValue(username: string) {
const usernameToValidate = getUsernameToValidate(username);
if (usernameToValidate.length < minUsernameLength) {
return ERROR_CODES.USERNAME_TOO_SHORT;
}
if (usernameToValidate.length > maxUsernameLength) {
return ERROR_CODES.USERNAME_TOO_LONG;
}
const valid = await validator(usernameToValidate);
if (!valid) {
return ERROR_CODES.INVALID_USERNAME;
}
return null;
}
async function validateUsername(
username: string,
displayUsername: string | null,
adapter: { findOne: <T>(opts: any) => Promise<T | null> },
currentUserId?: string | null,
) {
const usernameToValidate =
options?.validationOrder?.username === "post-normalization"
? normalizer(username)
: username;
if (usernameToValidate.length < minUsernameLength) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.USERNAME_TOO_SHORT);
}
if (usernameToValidate.length > maxUsernameLength) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.USERNAME_TOO_LONG);
}
const valid = await validator(usernameToValidate);
if (!valid) {
throw APIError.from("BAD_REQUEST", ERROR_CODES.INVALID_USERNAME);
const validationError = await validateUsernameValue(username);
if (validationError) {
throw APIError.from("BAD_REQUEST", validationError);
}
const normalizedUsername = normalizer(username);
@@ -614,6 +624,26 @@ export const username = (options?: UsernameOptions | undefined) => {
),
hooks: {
before: [
// Apply valid displayUsername fallback before validation so fallback usernames are checked.
{
matcher(context) {
return context.path === "/sign-up/email";
},
handler: createAuthMiddleware(async (ctx) => {
if (
typeof ctx.body.displayUsername !== "string" ||
ctx.body.username !== undefined
) {
return;
}
const validationError = await validateUsernameValue(
ctx.body.displayUsername,
);
if (!validationError) {
ctx.body.username = ctx.body.displayUsername;
}
}),
},
{
matcher(context) {
return (
@@ -622,40 +652,14 @@ export const username = (options?: UsernameOptions | undefined) => {
);
},
handler: createAuthMiddleware(async (ctx) => {
const username =
typeof ctx.body.username === "string" &&
options?.validationOrder?.username === "post-normalization"
? normalizer(ctx.body.username)
: ctx.body.username;
const username = ctx.body.username;
if (username !== undefined && typeof username === "string") {
const minUsernameLength = options?.minUsernameLength || 3;
const maxUsernameLength = options?.maxUsernameLength || 30;
if (username.length < minUsernameLength) {
throw APIError.from(
"BAD_REQUEST",
ERROR_CODES.USERNAME_TOO_SHORT,
);
const validationError = await validateUsernameValue(username);
if (validationError) {
throw APIError.from("BAD_REQUEST", validationError);
}
if (username.length > maxUsernameLength) {
throw APIError.from(
"BAD_REQUEST",
ERROR_CODES.USERNAME_TOO_LONG,
);
}
const validator =
options?.usernameValidator || defaultUsernameValidator;
const valid = await validator(username);
if (!valid) {
throw APIError.from(
"BAD_REQUEST",
ERROR_CODES.INVALID_USERNAME,
);
}
const normalizedUsername = normalizer(ctx.body.username);
const normalizedUsername = normalizer(username);
const existingUser = await ctx.context.adapter.findOne<User>({
model: "user",
where: [
@@ -715,9 +719,6 @@ export const username = (options?: UsernameOptions | undefined) => {
if (ctx.body.username && !ctx.body.displayUsername) {
ctx.body.displayUsername = ctx.body.username;
}
if (ctx.body.displayUsername && !ctx.body.username) {
ctx.body.username = ctx.body.displayUsername;
}
}),
},
],

View File

@@ -276,7 +276,7 @@ describe("username", async () => {
await client.signUp.email(
{
email: "display-test@email.com",
displayUsername: "Test Username",
displayUsername: "Test_Username",
password: "test-password",
name: "test-name",
},
@@ -292,8 +292,47 @@ describe("username", async () => {
},
});
expect(session?.user.username).toBe("test username");
expect(session?.user.displayUsername).toBe("Test Username");
expect(session?.user.username).toBe("test_username");
expect(session?.user.displayUsername).toBe("Test_Username");
});
it("should not store an invalid displayUsername-only value as username", async () => {
const headers = new Headers();
const res = await client.signUp.email(
{
email: "invalid-display-username@email.com",
displayUsername: "Invalid Username",
password: "test-password",
name: "test-name",
},
{
onSuccess: sessionSetter(headers),
},
);
expect(res.error).toBeNull();
const session = await client.getSession({
fetchOptions: {
headers,
throw: true,
},
});
expect(session?.user.username).toBeNull();
expect(session?.user.displayUsername).toBe("Invalid Username");
});
it("should not replace an explicit empty username with displayUsername", async () => {
const res = await client.signUp.email({
email: "empty-username@email.com",
username: "",
displayUsername: "valid_username",
password: "test-password",
name: "test-name",
});
expect(res.error?.status).toBe(400);
expect(res.error?.code).toBe(USERNAME_ERROR_CODES.USERNAME_TOO_SHORT.code);
});
it("should preserve both username and displayUsername when both are provided", async () => {
@@ -439,11 +478,14 @@ describe("username with displayUsername validation", async () => {
name: "test-name",
});
expect(res.error).toBeNull();
expect(res.data?.user.username).toBeNull();
expect(res.data?.user.displayUsername).toBe("Valid_Display-123");
});
it("should reject invalid displayUsername", async () => {
const res = await client.signUp.email({
email: "display-invalid@email.com",
username: "invalid_display",
displayUsername: "Invalid Display!",
password: "test-password",
name: "test-name",
@@ -454,6 +496,19 @@ describe("username with displayUsername validation", async () => {
);
});
it("should not validate inferred displayUsername during sign-up", async () => {
const res = await client.signUp.email({
email: "inferred-display@email.com",
username: "valid.username",
password: "test-password",
name: "test-name",
});
expect(res.error).toBeNull();
expect(res.data?.user.username).toBe("valid.username");
expect(res.data?.user.displayUsername).toBe("valid.username");
});
it("should update displayUsername with valid value", async () => {
const headers = new Headers();
await client.signUp.email(
@@ -501,6 +556,7 @@ describe("username with displayUsername validation", async () => {
await client.signUp.email(
{
email: "update-invalid@email.com",
username: "valid_name",
displayUsername: "Valid_Name",
password: "test-password",
name: "test-name",