better-auth
Version:
The most comprehensive authentication framework for TypeScript.
312 lines (311 loc) • 13.9 kB
JavaScript
import { mergeSchema, parseUserOutput } from "../../db/schema.mjs";
import { setSessionCookie } from "../../cookies/index.mjs";
import { getSessionFromCtx } from "../../api/routes/session.mjs";
import { createEmailVerificationToken } from "../../api/routes/email-verification.mjs";
import { PACKAGE_VERSION } from "../../version.mjs";
import { USERNAME_ERROR_CODES } from "./error-codes.mjs";
import { getSchema } from "./schema.mjs";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { createAuthEndpoint, createAuthMiddleware } from "@better-auth/core/api";
import * as z from "zod";
//#region src/plugins/username/index.ts
function defaultUsernameValidator(username) {
return /^[a-zA-Z0-9_.]+$/.test(username);
}
const signInUsernameBodySchema = z.object({
username: z.string().meta({ description: "The username of the user" }),
password: z.string().meta({ description: "The password of the user" }),
rememberMe: z.boolean().meta({ description: "Remember the user session" }).optional(),
callbackURL: z.string().meta({ description: "URL to redirect to after sign in (also used as the redirect target for email verification when required)" }).optional()
});
const isUsernameAvailableBodySchema = z.object({ username: z.string().meta({ description: "The username to check" }) });
const username = (options) => {
const normalizer = (username) => {
if (options?.usernameNormalization === false) return username;
if (options?.usernameNormalization) return options.usernameNormalization(username);
return username.toLowerCase();
};
const displayUsernameNormalizer = (displayUsername) => {
return options?.displayUsernameNormalization ? options.displayUsernameNormalization(displayUsername) : displayUsername;
};
const minUsernameLength = options?.minUsernameLength || 3;
const maxUsernameLength = options?.maxUsernameLength || 30;
const validator = options?.usernameValidator || defaultUsernameValidator;
const pathsWithHttpHookValidation = ["/sign-up/email", "/update-user"];
const getUsernameToValidate = (username) => options?.validationOrder?.username === "post-normalization" ? normalizer(username) : username;
async function validateUsernameValue(username) {
const usernameToValidate = getUsernameToValidate(username);
if (usernameToValidate.length < minUsernameLength) return USERNAME_ERROR_CODES.USERNAME_TOO_SHORT;
if (usernameToValidate.length > maxUsernameLength) return USERNAME_ERROR_CODES.USERNAME_TOO_LONG;
if (!await validator(usernameToValidate)) return USERNAME_ERROR_CODES.INVALID_USERNAME;
return null;
}
async function validateUsername(username, displayUsername, adapter, currentUserId) {
const validationError = await validateUsernameValue(username);
if (validationError) throw APIError.from("BAD_REQUEST", validationError);
const normalizedUsername = normalizer(username);
const existingUser = await adapter.findOne({
model: "user",
where: [{
field: "username",
value: normalizedUsername
}]
});
if (existingUser) {
if (!currentUserId || existingUser.id !== currentUserId) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_IS_ALREADY_TAKEN);
}
if (displayUsername && options?.displayUsernameValidator) {
const displayUsernameToValidate = options?.validationOrder?.displayUsername === "post-normalization" ? displayUsernameNormalizer(displayUsername) : displayUsername;
if (!await options.displayUsernameValidator(displayUsernameToValidate)) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.INVALID_DISPLAY_USERNAME);
}
}
return {
id: "username",
version: PACKAGE_VERSION,
init(ctx) {
return { options: { databaseHooks: { user: {
create: { async before(user, context) {
const username = "username" in user ? user.username : null;
const displayUsername = "displayUsername" in user ? user.displayUsername : null;
const currentPath = context?.path;
const skipValidation = currentPath && pathsWithHttpHookValidation.includes(currentPath);
if (username) {
if (!skipValidation) await validateUsername(username, displayUsername, ctx.adapter);
return { data: {
...user,
username: normalizer(username),
displayUsername: displayUsername ? displayUsernameNormalizer(displayUsername) : username
} };
}
return { data: {
...user,
...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
} };
} },
update: { async before(user, context) {
const username = "username" in user ? user.username : null;
const displayUsername = "displayUsername" in user ? user.displayUsername : null;
const currentPath = context?.path;
const skipValidation = currentPath && pathsWithHttpHookValidation.includes(currentPath);
if (username) {
if (!skipValidation) {
const currentUserId = context?.context?.session?.user?.id || ("id" in user ? user.id : null);
await validateUsername(username, displayUsername, ctx.adapter, currentUserId);
}
return { data: {
...user,
username: normalizer(username),
...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
} };
}
return { data: {
...user,
...displayUsername ? { displayUsername: displayUsernameNormalizer(displayUsername) } : {}
} };
} }
} } } };
},
endpoints: {
signInUsername: createAuthEndpoint("/sign-in/username", {
method: "POST",
body: signInUsernameBodySchema,
metadata: { openapi: {
summary: "Sign in with username",
description: "Sign in with username",
responses: {
200: {
description: "Success",
content: { "application/json": { schema: {
type: "object",
properties: {
redirect: {
type: "boolean",
description: "Whether the client should follow the Location header. True when callbackURL was provided."
},
token: {
type: "string",
description: "Session token for the authenticated session"
},
url: {
type: "string",
nullable: true,
description: "The callbackURL echoed back so the client can redirect."
},
user: { $ref: "#/components/schemas/User" }
},
required: [
"redirect",
"token",
"user"
]
} } }
},
422: {
description: "Unprocessable Entity. Validation error",
content: { "application/json": { schema: {
type: "object",
properties: { message: { type: "string" } }
} } }
}
}
} }
}, async (ctx) => {
if (!ctx.body.username || !ctx.body.password) {
ctx.context.logger.warn("Username or password not found");
throw APIError.from("UNAUTHORIZED", USERNAME_ERROR_CODES.INVALID_USERNAME_OR_PASSWORD);
}
const username = options?.validationOrder?.username === "pre-normalization" ? normalizer(ctx.body.username) : ctx.body.username;
const minUsernameLength = options?.minUsernameLength || 3;
const maxUsernameLength = options?.maxUsernameLength || 30;
if (username.length < minUsernameLength) {
ctx.context.logger.warn("Username too short");
throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.USERNAME_TOO_SHORT);
}
if (username.length > maxUsernameLength) {
ctx.context.logger.warn("Username too long");
throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.USERNAME_TOO_LONG);
}
if (!await (options?.usernameValidator || defaultUsernameValidator)(username)) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.INVALID_USERNAME);
const user = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "username",
value: normalizer(username)
}]
});
if (!user) {
await ctx.context.password.hash(ctx.body.password);
ctx.context.logger.warn("User not found");
throw APIError.from("UNAUTHORIZED", USERNAME_ERROR_CODES.INVALID_USERNAME_OR_PASSWORD);
}
const account = await ctx.context.adapter.findOne({
model: "account",
where: [{
field: "userId",
value: user.id
}, {
field: "providerId",
value: "credential"
}]
});
if (!account) throw APIError.from("UNAUTHORIZED", USERNAME_ERROR_CODES.INVALID_USERNAME_OR_PASSWORD);
const currentPassword = account?.password;
if (!currentPassword) {
ctx.context.logger.warn("Password not found");
throw APIError.from("UNAUTHORIZED", USERNAME_ERROR_CODES.INVALID_USERNAME_OR_PASSWORD);
}
if (!await ctx.context.password.verify({
hash: currentPassword,
password: ctx.body.password
})) {
ctx.context.logger.warn("Invalid password");
throw APIError.from("UNAUTHORIZED", USERNAME_ERROR_CODES.INVALID_USERNAME_OR_PASSWORD);
}
if (ctx.context.options?.emailAndPassword?.requireEmailVerification && !user.emailVerified) {
if (!ctx.context.options?.emailVerification?.sendVerificationEmail) throw APIError.from("FORBIDDEN", USERNAME_ERROR_CODES.EMAIL_NOT_VERIFIED);
if (ctx.context.options?.emailVerification?.sendOnSignIn) {
const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn);
const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${encodeURIComponent(ctx.body.callbackURL || "/")}`;
await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({
user,
url,
token
}, ctx.request));
}
throw APIError.from("FORBIDDEN", USERNAME_ERROR_CODES.EMAIL_NOT_VERIFIED);
}
const session = await ctx.context.internalAdapter.createSession(user.id, ctx.body.rememberMe === false);
if (!session) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION);
await setSessionCookie(ctx, {
session,
user
}, ctx.body.rememberMe === false);
if (ctx.body.callbackURL) ctx.setHeader("Location", ctx.body.callbackURL);
return ctx.json({
redirect: !!ctx.body.callbackURL,
token: session.token,
url: ctx.body.callbackURL,
user: parseUserOutput(ctx.context.options, user)
});
}),
isUsernameAvailable: createAuthEndpoint("/is-username-available", {
method: "POST",
body: isUsernameAvailableBodySchema
}, async (ctx) => {
const username = ctx.body.username;
if (!username) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.INVALID_USERNAME);
const minUsernameLength = options?.minUsernameLength || 3;
const maxUsernameLength = options?.maxUsernameLength || 30;
if (username.length < minUsernameLength) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.USERNAME_TOO_SHORT);
if (username.length > maxUsernameLength) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.USERNAME_TOO_LONG);
if (!await (options?.usernameValidator || defaultUsernameValidator)(username)) throw APIError.from("UNPROCESSABLE_ENTITY", USERNAME_ERROR_CODES.INVALID_USERNAME);
if (await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "username",
value: normalizer(username)
}]
})) return ctx.json({ available: false });
return ctx.json({ available: true });
})
},
schema: mergeSchema(getSchema({
username: normalizer,
displayUsername: displayUsernameNormalizer
}), options?.schema),
hooks: { before: [
{
matcher(context) {
return context.path === "/sign-up/email";
},
handler: createAuthMiddleware(async (ctx) => {
if (typeof ctx.body.displayUsername !== "string" || ctx.body.username !== void 0) return;
if (!await validateUsernameValue(ctx.body.displayUsername)) ctx.body.username = ctx.body.displayUsername;
})
},
{
matcher(context) {
return context.path === "/sign-up/email" || context.path === "/update-user";
},
handler: createAuthMiddleware(async (ctx) => {
const username = ctx.body.username;
if (username !== void 0 && typeof username === "string") {
const validationError = await validateUsernameValue(username);
if (validationError) throw APIError.from("BAD_REQUEST", validationError);
const normalizedUsername = normalizer(username);
const existingUser = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "username",
value: normalizedUsername
}]
});
if (ctx.path === "/sign-up/email" && existingUser) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_IS_ALREADY_TAKEN);
if (ctx.path === "/update-user" && existingUser) {
const session = await getSessionFromCtx(ctx);
if (!session || existingUser.id !== session.user.id) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.USERNAME_IS_ALREADY_TAKEN);
}
}
const displayUsername = typeof ctx.body.displayUsername === "string" && options?.validationOrder?.displayUsername === "post-normalization" ? displayUsernameNormalizer(ctx.body.displayUsername) : ctx.body.displayUsername;
if (displayUsername !== void 0 && typeof displayUsername === "string") {
if (options?.displayUsernameValidator) {
if (!await options.displayUsernameValidator(displayUsername)) throw APIError.from("BAD_REQUEST", USERNAME_ERROR_CODES.INVALID_DISPLAY_USERNAME);
}
}
})
},
{
matcher(context) {
return context.path === "/sign-up/email";
},
handler: createAuthMiddleware(async (ctx) => {
if (ctx.body.username && !ctx.body.displayUsername) ctx.body.displayUsername = ctx.body.username;
})
}
] },
options,
$ERROR_CODES: USERNAME_ERROR_CODES
};
};
//#endregion
export { USERNAME_ERROR_CODES, username };