Skip to content

Commit 41fb4d1

Browse files
bortozBekacru
andauthored
feat(username): add custom username normalization option (#3412)
* feat(username): add custom username normalization option * add transformer * handle nullish values --------- Co-authored-by: Bereket Engida <Bekacru@gmail.com>
1 parent d66c6c9 commit 41fb4d1

File tree

4 files changed

+109
-25
lines changed

4 files changed

+109
-25
lines changed

docs/content/docs/plugins/username.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,27 @@ const auth = betterAuth({
190190
]
191191
})
192192
```
193+
194+
### Username Normalization
195+
196+
A function that normalizes the username, or `false` if you want to disable normalization.
197+
198+
By default, usernames are case-insensitive, so "TestUser" and "testuser", for example, are considered the same username. The `username` field will contain the normalized (lower case) username, while `displayUsername` will contain the original `username`.
199+
200+
```ts title="auth.ts"
201+
import { betterAuth } from "better-auth"
202+
import { username } from "better-auth/plugins"
203+
204+
const auth = betterAuth({
205+
plugins: [
206+
username({
207+
usernameNormalization: (username) => {
208+
return username.toLowerCase()
209+
.replaceAll("0", "o")
210+
.replaceAll("3", "e")
211+
.replaceAll("4", "a");
212+
}
213+
})
214+
]
215+
})
216+
```

packages/better-auth/src/plugins/username/index.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import type { Account, InferOptionSchema, User } from "../../types";
66
import { setSessionCookie } from "../../cookies";
77
import { sendVerificationEmailFn } from "../../api";
88
import { BASE_ERROR_CODES } from "../../error/codes";
9-
import { schema } from "./schema";
9+
import { getSchema, type UsernameSchema } from "./schema";
1010
import { mergeSchema } from "../../db/schema";
1111
import { USERNAME_ERROR_CODES as ERROR_CODES } from "./error-codes";
1212
export * from "./error-codes";
1313
export type UsernameOptions = {
14-
schema?: InferOptionSchema<typeof schema>;
14+
schema?: InferOptionSchema<UsernameSchema>;
1515
/**
1616
* The minimum length of the username
1717
*
@@ -30,13 +30,29 @@ export type UsernameOptions = {
3030
* By default, the username should only contain alphanumeric characters and underscores
3131
*/
3232
usernameValidator?: (username: string) => boolean | Promise<boolean>;
33+
/**
34+
* A function to normalize the username
35+
*
36+
* @default (username) => username.toLowerCase()
37+
*/
38+
usernameNormalization?: ((username: string) => string) | false;
3339
};
3440

3541
function defaultUsernameValidator(username: string) {
3642
return /^[a-zA-Z0-9_.]+$/.test(username);
3743
}
3844

3945
export const username = (options?: UsernameOptions) => {
46+
const normalizer = (username: string) => {
47+
if (options?.usernameNormalization === false) {
48+
return username;
49+
}
50+
if (options?.usernameNormalization) {
51+
return options.usernameNormalization(username);
52+
}
53+
return username.toLowerCase();
54+
};
55+
4056
return {
4157
id: "username",
4258
endpoints: {
@@ -139,7 +155,7 @@ export const username = (options?: UsernameOptions) => {
139155
where: [
140156
{
141157
field: "username",
142-
value: ctx.body.username.toLowerCase(),
158+
value: normalizer(ctx.body.username),
143159
},
144160
],
145161
});
@@ -272,7 +288,7 @@ export const username = (options?: UsernameOptions) => {
272288
},
273289
),
274290
},
275-
schema: mergeSchema(schema, options?.schema),
291+
schema: mergeSchema(getSchema(normalizer), options?.schema),
276292
hooks: {
277293
before: [
278294
{
@@ -313,7 +329,7 @@ export const username = (options?: UsernameOptions) => {
313329
where: [
314330
{
315331
field: "username",
316-
value: username.toLowerCase(),
332+
value: normalizer(username),
317333
},
318334
],
319335
});
@@ -340,8 +356,9 @@ export const username = (options?: UsernameOptions) => {
340356
);
341357
},
342358
handler: createAuthMiddleware(async (ctx) => {
343-
if (!ctx.body.displayUsername && ctx.body.username) {
344-
ctx.body.displayUsername = ctx.body.username;
359+
if (ctx.body.username) {
360+
ctx.body.displayUsername ||= ctx.body.username;
361+
ctx.body.username = normalizer(ctx.body.username);
345362
}
346363
}),
347364
},
Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
11
import type { AuthPluginSchema } from "../../types";
22

3-
export const schema = {
4-
user: {
5-
fields: {
6-
username: {
7-
type: "string",
8-
required: false,
9-
sortable: true,
10-
unique: true,
11-
returned: true,
12-
transform: {
13-
input(value) {
14-
return value?.toString().toLowerCase();
3+
export const getSchema = (normalizer: (username: string) => string) => {
4+
return {
5+
user: {
6+
fields: {
7+
username: {
8+
type: "string",
9+
required: false,
10+
sortable: true,
11+
unique: true,
12+
returned: true,
13+
},
14+
displayUsername: {
15+
type: "string",
16+
required: false,
17+
transform: {
18+
input(value) {
19+
return value == null ? value : normalizer(value as string);
20+
},
1521
},
1622
},
1723
},
18-
displayUsername: {
19-
type: "string",
20-
required: false,
21-
},
2224
},
23-
},
24-
} satisfies AuthPluginSchema;
25+
} satisfies AuthPluginSchema;
26+
};
27+
28+
export type UsernameSchema = ReturnType<typeof getSchema>;

packages/better-auth/src/plugins/username/username.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,42 @@ describe("username", async (it) => {
165165
expect(res.data?.available).toEqual(true);
166166
});
167167
});
168+
169+
describe("username custom normalization", async (it) => {
170+
const { client } = await getTestInstance(
171+
{
172+
plugins: [
173+
username({
174+
minUsernameLength: 4,
175+
usernameNormalization: (username) =>
176+
username.replaceAll("0", "o").replaceAll("4", "a").toLowerCase(),
177+
}),
178+
],
179+
},
180+
{
181+
clientOptions: {
182+
plugins: [usernameClient()],
183+
},
184+
},
185+
);
186+
187+
it("should sign up with username", async () => {
188+
const res = await client.signUp.email({
189+
email: "new-email@gamil.com",
190+
username: "H4XX0R",
191+
password: "new-password",
192+
name: "new-name",
193+
});
194+
expect(res.error).toBeNull();
195+
});
196+
197+
it("should fail on duplicate username", async () => {
198+
const res = await client.signUp.email({
199+
email: "new-email-2@gamil.com",
200+
username: "haxxor",
201+
password: "new-password",
202+
name: "new-name",
203+
});
204+
expect(res.error?.status).toBe(422);
205+
});
206+
});

0 commit comments

Comments
 (0)