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
This commit is contained in:
Bereket Engida
2025-02-14 18:15:19 +03:00
committed by Bereket Engida
parent 1affe01986
commit c4f2087943
3 changed files with 183 additions and 15 deletions

View File

@@ -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
}
}
})
]
})
```

View File

@@ -12,18 +12,39 @@ import { mergeSchema } from "../../db/schema";
export type UsernameOptions = {
schema?: InferOptionSchema<typeof schema>;
/**
* 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<boolean>;
};
export interface UserWithUsername extends User {
username: string;
function defaultUsernameValidator(username: string) {
return /^[a-zA-Z0-9_]+$/.test(username);
}
export const username = <Opts extends UsernameOptions>(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 = <Opts extends UsernameOptions>(options?: Opts) => {
},
},
async (ctx) => {
const user = await ctx.context.adapter.findOne<UserWithUsername>({
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 = <Opts extends UsernameOptions>(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<User>({
model: "user",
where: [

View File

@@ -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");
});
});