UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

312 lines (311 loc) • 13.9 kB
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 };