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 = { export type UsernameOptions = {
schema?: InferOptionSchema<typeof schema>; 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 { function defaultUsernameValidator(username: string) {
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 = { const ERROR_CODES = {
INVALID_USERNAME_OR_PASSWORD: "invalid username or password", INVALID_USERNAME_OR_PASSWORD: "invalid username or password",
EMAIL_NOT_VERIFIED: "email not verified", EMAIL_NOT_VERIFIED: "email not verified",
UNEXPECTED_ERROR: "unexpected error", UNEXPECTED_ERROR: "unexpected error",
USERNAME_IS_ALREADY_TAKEN: "username is already taken. please try another.", 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 { return {
id: "username", id: "username",
@@ -73,7 +94,46 @@ export const username = <Opts extends UsernameOptions>(options?: Opts) => {
}, },
}, },
async (ctx) => { 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", model: "user",
where: [ where: [
{ {
@@ -174,11 +234,38 @@ export const username = <Opts extends UsernameOptions>(options?: Opts) => {
before: [ before: [
{ {
matcher(context) { matcher(context) {
return context.path === "/sign-up/email"; return (
context.path === "/sign-up/email" ||
context.path === "/update-user"
);
}, },
handler: createAuthMiddleware(async (ctx) => { handler: createAuthMiddleware(async (ctx) => {
const username = ctx.body.username; const username = ctx.body.username;
if (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>({ const user = await ctx.context.adapter.findOne<User>({
model: "user", model: "user",
where: [ where: [

View File

@@ -6,7 +6,11 @@ import { usernameClient } from "./client";
describe("username", async (it) => { describe("username", async (it) => {
const { client, sessionSetter } = await getTestInstance( const { client, sessionSetter } = await getTestInstance(
{ {
plugins: [username()], plugins: [
username({
minUsernameLength: 4,
}),
],
}, },
{ {
clientOptions: { clientOptions: {
@@ -20,7 +24,7 @@ describe("username", async (it) => {
await client.signUp.email( await client.signUp.email(
{ {
email: "new-email@gamil.com", email: "new-email@gamil.com",
username: "New-Username", username: "new_username",
password: "new-password", password: "new-password",
name: "new-name", name: "new-name",
}, },
@@ -34,14 +38,13 @@ describe("username", async (it) => {
throw: true, throw: true,
}, },
}); });
expect(session?.user.username).toBe("new-username"); expect(session?.user.username).toBe("new_username");
expect(session?.user.displayUsername).toBe("New-Username");
}); });
const headers = new Headers(); const headers = new Headers();
it("should sign-in with username", async () => { it("should sign-in with username", async () => {
const res = await client.signIn.username( const res = await client.signIn.username(
{ {
username: "new-username", username: "new_username",
password: "new-password", password: "new-password",
}, },
{ {
@@ -52,7 +55,7 @@ describe("username", async (it) => {
}); });
it("should update username", async () => { it("should update username", async () => {
const res = await client.updateUser({ const res = await client.updateUser({
username: "new-Username-2", username: "new_username_2",
fetchOptions: { fetchOptions: {
headers, headers,
}, },
@@ -64,17 +67,38 @@ describe("username", async (it) => {
throw: true, throw: true,
}, },
}); });
expect(session?.user.username).toBe("new-username-2"); expect(session?.user.username).toBe("new_username_2");
expect(session?.user.displayUsername).toBe("new-Username-2");
}); });
it("should fail on duplicate username", async () => { it("should fail on duplicate username", async () => {
const res = await client.signUp.email({ const res = await client.signUp.email({
email: "new-email-2@gamil.com", email: "new-email-2@gamil.com",
username: "New-username-2", username: "New_username_2",
password: "new-password", password: "new_password",
name: "new-name", name: "new-name",
}); });
expect(res.error?.status).toBe(422); 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");
});
}); });