From c4f20879433eeb09565f876976bba75c6ce2642c Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Fri, 14 Feb 2025 18:15:19 +0300 Subject: [PATCH] feat(username): add default validation and options for validating username (#1345) * feat: add default validation and options for validating username * chore: release v1.1.16-beta.5 * fix: include update-user * chore: release v1.1.16-beta.6 --- docs/content/docs/plugins/username.mdx | 57 +++++++++++ .../better-auth/src/plugins/username/index.ts | 97 ++++++++++++++++++- .../src/plugins/username/username.test.ts | 44 +++++++-- 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/plugins/username.mdx b/docs/content/docs/plugins/username.mdx index a26a0a47..f0c6e49d 100644 --- a/docs/content/docs/plugins/username.mdx +++ b/docs/content/docs/plugins/username.mdx @@ -110,3 +110,60 @@ The plugin requires 1 field to be added to the user table: }, ]} /> + +## Options + +### Min Username Length + +The minimum length of the username. Default is `3`. + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { username } from "better-auth/plugins" + +const auth = betterAuth({ + plugins: [ + username({ + minUsernameLength: 5 + }) + ] +}) +``` + +### Max Username Length + +The maximum length of the username. Default is `30`. + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { username } from "better-auth/plugins" + +const auth = betterAuth({ + plugins: [ + username({ + maxUsernameLength: 100 + }) + ] +}) +``` + +### Username Validator + +A function that validates the username. The function should return false if the username is invalid. By default, the username should only contain alphanumeric characters and underscores. + +```ts title="auth.ts" +import { betterAuth } from "better-auth" +import { username } from "better-auth/plugins" + +const auth = betterAuth({ + plugins: [ + username({ + usernameValidator: (username) => { + if (username === "admin") { + return false + } + } + }) + ] +}) +``` diff --git a/packages/better-auth/src/plugins/username/index.ts b/packages/better-auth/src/plugins/username/index.ts index d0de3199..50eb0a7d 100644 --- a/packages/better-auth/src/plugins/username/index.ts +++ b/packages/better-auth/src/plugins/username/index.ts @@ -12,18 +12,39 @@ import { mergeSchema } from "../../db/schema"; export type UsernameOptions = { schema?: InferOptionSchema; + /** + * The minimum length of the username + * + * @default 3 + */ + minUsernameLength?: number; + /** + * The maximum length of the username + * + * @default 30 + */ + maxUsernameLength?: number; + /** + * A function to validate the username + * + * By default, the username should only contain alphanumeric characters and underscores + */ + usernameValidator?: (username: string) => boolean | Promise; }; -export interface UserWithUsername extends User { - username: string; +function defaultUsernameValidator(username: string) { + return /^[a-zA-Z0-9_]+$/.test(username); } -export const username = (options?: Opts) => { +export const username = (options?: UsernameOptions) => { const ERROR_CODES = { INVALID_USERNAME_OR_PASSWORD: "invalid username or password", EMAIL_NOT_VERIFIED: "email not verified", UNEXPECTED_ERROR: "unexpected error", USERNAME_IS_ALREADY_TAKEN: "username is already taken. please try another.", + USERNAME_TOO_SHORT: "username is too short", + USERNAME_TOO_LONG: "username is too long", + INVALID_USERNAME: "username is invalid", }; return { id: "username", @@ -73,7 +94,46 @@ export const username = (options?: Opts) => { }, }, async (ctx) => { - const user = await ctx.context.adapter.findOne({ + if (!ctx.body.username || !ctx.body.password) { + ctx.context.logger.error("Username or password not found"); + throw new APIError("UNAUTHORIZED", { + message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, + }); + } + + const minUsernameLength = options?.minUsernameLength || 3; + const maxUsernameLength = options?.maxUsernameLength || 30; + + if (ctx.body.username.length < minUsernameLength) { + ctx.context.logger.error("Username too short", { + username: ctx.body.username, + }); + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.USERNAME_TOO_SHORT, + }); + } + + if (ctx.body.username.length > maxUsernameLength) { + ctx.context.logger.error("Username too long", { + username: ctx.body.username, + }); + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.USERNAME_TOO_LONG, + }); + } + + const validator = + options?.usernameValidator || defaultUsernameValidator; + + if (!validator(ctx.body.username)) { + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.INVALID_USERNAME, + }); + } + + const user = await ctx.context.adapter.findOne< + User & { username: string } + >({ model: "user", where: [ { @@ -174,11 +234,38 @@ export const username = (options?: Opts) => { before: [ { matcher(context) { - return context.path === "/sign-up/email"; + return ( + context.path === "/sign-up/email" || + context.path === "/update-user" + ); }, handler: createAuthMiddleware(async (ctx) => { const username = ctx.body.username; if (username) { + const minUsernameLength = options?.minUsernameLength || 3; + const maxUsernameLength = options?.maxUsernameLength || 30; + if (username.length < minUsernameLength) { + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.USERNAME_TOO_SHORT, + }); + } + + if (username.length > maxUsernameLength) { + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.USERNAME_TOO_LONG, + }); + } + + const validator = + options?.usernameValidator || defaultUsernameValidator; + + const valid = await validator(username); + if (!valid) { + throw new APIError("UNPROCESSABLE_ENTITY", { + message: ERROR_CODES.INVALID_USERNAME, + }); + } + const user = await ctx.context.adapter.findOne({ model: "user", where: [ diff --git a/packages/better-auth/src/plugins/username/username.test.ts b/packages/better-auth/src/plugins/username/username.test.ts index 49f9b771..1fbdc554 100644 --- a/packages/better-auth/src/plugins/username/username.test.ts +++ b/packages/better-auth/src/plugins/username/username.test.ts @@ -6,7 +6,11 @@ import { usernameClient } from "./client"; describe("username", async (it) => { const { client, sessionSetter } = await getTestInstance( { - plugins: [username()], + plugins: [ + username({ + minUsernameLength: 4, + }), + ], }, { clientOptions: { @@ -20,7 +24,7 @@ describe("username", async (it) => { await client.signUp.email( { email: "new-email@gamil.com", - username: "New-Username", + username: "new_username", password: "new-password", name: "new-name", }, @@ -34,14 +38,13 @@ describe("username", async (it) => { throw: true, }, }); - expect(session?.user.username).toBe("new-username"); - expect(session?.user.displayUsername).toBe("New-Username"); + expect(session?.user.username).toBe("new_username"); }); const headers = new Headers(); it("should sign-in with username", async () => { const res = await client.signIn.username( { - username: "new-username", + username: "new_username", password: "new-password", }, { @@ -52,7 +55,7 @@ describe("username", async (it) => { }); it("should update username", async () => { const res = await client.updateUser({ - username: "new-Username-2", + username: "new_username_2", fetchOptions: { headers, }, @@ -64,17 +67,38 @@ describe("username", async (it) => { throw: true, }, }); - expect(session?.user.username).toBe("new-username-2"); - expect(session?.user.displayUsername).toBe("new-Username-2"); + expect(session?.user.username).toBe("new_username_2"); }); it("should fail on duplicate username", async () => { const res = await client.signUp.email({ email: "new-email-2@gamil.com", - username: "New-username-2", - password: "new-password", + username: "New_username_2", + password: "new_password", name: "new-name", }); expect(res.error?.status).toBe(422); }); + + it("should fail on invalid username", async () => { + const res = await client.signUp.email({ + email: "email-4@email.com", + username: "new username", + password: "new_password", + name: "new-name", + }); + expect(res.error?.status).toBe(422); + expect(res.error?.code).toBe("USERNAME_IS_INVALID"); + }); + + it("should fail on too short username", async () => { + const res = await client.signUp.email({ + email: "email-4@email.com", + username: "new", + password: "new_password", + name: "new-name", + }); + expect(res.error?.status).toBe(422); + expect(res.error?.code).toBe("USERNAME_IS_TOO_SHORT"); + }); });