diff --git a/.changeset/slow-rules-reflect.md b/.changeset/slow-rules-reflect.md new file mode 100644 index 0000000000..3cad3a3a6f --- /dev/null +++ b/.changeset/slow-rules-reflect.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Only store display username fallbacks as usernames when they pass username validation during email sign-up. diff --git a/packages/better-auth/src/plugins/username/index.ts b/packages/better-auth/src/plugins/username/index.ts index 77d0340743..48e91a0cc5 100644 --- a/packages/better-auth/src/plugins/username/index.ts +++ b/packages/better-auth/src/plugins/username/index.ts @@ -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: (opts: any) => Promise }, 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({ 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; - } }), }, ], diff --git a/packages/better-auth/src/plugins/username/username.test.ts b/packages/better-auth/src/plugins/username/username.test.ts index 031f445f6b..23d76c07b7 100644 --- a/packages/better-auth/src/plugins/username/username.test.ts +++ b/packages/better-auth/src/plugins/username/username.test.ts @@ -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",