UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

499 lines (497 loc) • 19.2 kB
import { parseUserInput } from "../../db/schema.mjs"; import { originCheck } from "../middlewares/origin-check.mjs"; import "../middlewares/index.mjs"; import { generateRandomString } from "../../crypto/random.mjs"; import "../../crypto/index.mjs"; import { deleteSessionCookie, setSessionCookie } from "../../cookies/index.mjs"; import { getSessionFromCtx, sensitiveSessionMiddleware, sessionMiddleware } from "./session.mjs"; import { createEmailVerificationToken } from "./email-verification.mjs"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import * as z from "zod"; import { APIError } from "better-call"; import { createAuthEndpoint } from "@better-auth/core/api"; //#region src/api/routes/update-user.ts const updateUserBodySchema = z.record(z.string().meta({ description: "Field name must be a string" }), z.any()); const updateUser = () => createAuthEndpoint("/update-user", { method: "POST", operationId: "updateUser", body: updateUserBodySchema, use: [sessionMiddleware], metadata: { $Infer: { body: {} }, openapi: { operationId: "updateUser", description: "Update the current user", requestBody: { content: { "application/json": { schema: { type: "object", properties: { name: { type: "string", description: "The name of the user" }, image: { type: "string", description: "The image of the user", nullable: true } } } } } }, responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", $ref: "#/components/schemas/User" } } } } } } } } } }, async (ctx) => { const body = ctx.body; if (typeof body !== "object" || Array.isArray(body)) throw new APIError("BAD_REQUEST", { message: "Body must be an object" }); if (body.email) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.EMAIL_CAN_NOT_BE_UPDATED }); const { name, image, ...rest } = body; const session = ctx.context.session; const additionalFields = parseUserInput(ctx.context.options, rest, "update"); if (image === void 0 && name === void 0 && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields to update" }); const updatedUser = await ctx.context.internalAdapter.updateUser(session.user.id, { name, image, ...additionalFields }) ?? { ...session.user, ...name !== void 0 && { name }, ...image !== void 0 && { image }, ...additionalFields }; /** * Update the session cookie with the new user data */ await setSessionCookie(ctx, { session: session.session, user: updatedUser }); return ctx.json({ status: true }); }); const changePassword = createAuthEndpoint("/change-password", { method: "POST", operationId: "changePassword", body: z.object({ newPassword: z.string().meta({ description: "The new password to set" }), currentPassword: z.string().meta({ description: "The current password is required" }), revokeOtherSessions: z.boolean().meta({ description: "Must be a boolean value" }).optional() }), use: [sensitiveSessionMiddleware], metadata: { openapi: { operationId: "changePassword", description: "Change the password of the user", responses: { "200": { description: "Password successfully changed", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", nullable: true, description: "New session token if other sessions were revoked" }, user: { type: "object", properties: { id: { type: "string", description: "The unique identifier of the user" }, email: { type: "string", format: "email", description: "The email address of the user" }, name: { type: "string", description: "The name of the user" }, image: { type: "string", format: "uri", nullable: true, description: "The profile image URL of the user" }, emailVerified: { type: "boolean", description: "Whether the email has been verified" }, createdAt: { type: "string", format: "date-time", description: "When the user was created" }, updatedAt: { type: "string", format: "date-time", description: "When the user was last updated" } }, required: [ "id", "email", "name", "emailVerified", "createdAt", "updatedAt" ] } }, required: ["user"] } } } } } } } }, async (ctx) => { const { newPassword, currentPassword, revokeOtherSessions } = ctx.body; const session = ctx.context.session; const minPasswordLength = ctx.context.password.config.minPasswordLength; if (newPassword.length < minPasswordLength) { ctx.context.logger.error("Password is too short"); throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_SHORT }); } const maxPasswordLength = ctx.context.password.config.maxPasswordLength; if (newPassword.length > maxPasswordLength) { ctx.context.logger.error("Password is too long"); throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_LONG }); } const account = (await ctx.context.internalAdapter.findAccounts(session.user.id)).find((account$1) => account$1.providerId === "credential" && account$1.password); if (!account || !account.password) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND }); const passwordHash = await ctx.context.password.hash(newPassword); if (!await ctx.context.password.verify({ hash: account.password, password: currentPassword })) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_PASSWORD }); await ctx.context.internalAdapter.updateAccount(account.id, { password: passwordHash }); let token = null; if (revokeOtherSessions) { await ctx.context.internalAdapter.deleteSessions(session.user.id); const newSession = await ctx.context.internalAdapter.createSession(session.user.id); if (!newSession) throw new APIError("INTERNAL_SERVER_ERROR", { message: BASE_ERROR_CODES.FAILED_TO_GET_SESSION }); await setSessionCookie(ctx, { session: newSession, user: session.user }); token = newSession.token; } return ctx.json({ token, user: { id: session.user.id, email: session.user.email, name: session.user.name, image: session.user.image, emailVerified: session.user.emailVerified, createdAt: session.user.createdAt, updatedAt: session.user.updatedAt } }); }); const setPassword = createAuthEndpoint({ method: "POST", body: z.object({ newPassword: z.string().meta({ description: "The new password to set is required" }) }), use: [sensitiveSessionMiddleware] }, async (ctx) => { const { newPassword } = ctx.body; const session = ctx.context.session; const minPasswordLength = ctx.context.password.config.minPasswordLength; if (newPassword.length < minPasswordLength) { ctx.context.logger.error("Password is too short"); throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_SHORT }); } const maxPasswordLength = ctx.context.password.config.maxPasswordLength; if (newPassword.length > maxPasswordLength) { ctx.context.logger.error("Password is too long"); throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.PASSWORD_TOO_LONG }); } const account = (await ctx.context.internalAdapter.findAccounts(session.user.id)).find((account$1) => account$1.providerId === "credential" && account$1.password); const passwordHash = await ctx.context.password.hash(newPassword); if (!account) { await ctx.context.internalAdapter.linkAccount({ userId: session.user.id, providerId: "credential", accountId: session.user.id, password: passwordHash }); return ctx.json({ status: true }); } throw new APIError("BAD_REQUEST", { message: "user already has a password" }); }); const deleteUser = createAuthEndpoint("/delete-user", { method: "POST", use: [sensitiveSessionMiddleware], body: z.object({ callbackURL: z.string().meta({ description: "The callback URL to redirect to after the user is deleted" }).optional(), password: z.string().meta({ description: "The password of the user is required to delete the user" }).optional(), token: z.string().meta({ description: "The token to delete the user is required" }).optional() }), metadata: { openapi: { operationId: "deleteUser", description: "Delete the user", requestBody: { content: { "application/json": { schema: { type: "object", properties: { callbackURL: { type: "string", description: "The callback URL to redirect to after the user is deleted" }, password: { type: "string", description: "The user's password. Required if session is not fresh" }, token: { type: "string", description: "The deletion verification token" } } } } } }, responses: { "200": { description: "User deletion processed successfully", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", description: "Indicates if the operation was successful" }, message: { type: "string", enum: ["User deleted", "Verification email sent"], description: "Status message of the deletion process" } }, required: ["success", "message"] } } } } } } } }, async (ctx) => { if (!ctx.context.options.user?.deleteUser?.enabled) { ctx.context.logger.error("Delete user is disabled. Enable it in the options"); throw new APIError("NOT_FOUND"); } const session = ctx.context.session; if (ctx.body.password) { const account = (await ctx.context.internalAdapter.findAccounts(session.user.id)).find((account$1) => account$1.providerId === "credential" && account$1.password); if (!account || !account.password) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND }); if (!await ctx.context.password.verify({ hash: account.password, password: ctx.body.password })) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.INVALID_PASSWORD }); } if (ctx.body.token) { await deleteUserCallback({ ...ctx, query: { token: ctx.body.token } }); return ctx.json({ success: true, message: "User deleted" }); } if (ctx.context.options.user.deleteUser?.sendDeleteAccountVerification) { const token = generateRandomString(32, "0-9", "a-z"); await ctx.context.internalAdapter.createVerificationValue({ value: session.user.id, identifier: `delete-account-${token}`, expiresAt: new Date(Date.now() + (ctx.context.options.user.deleteUser?.deleteTokenExpiresIn || 3600 * 24) * 1e3) }); const url = `${ctx.context.baseURL}/delete-user/callback?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`; await ctx.context.runInBackgroundOrAwait(ctx.context.options.user.deleteUser.sendDeleteAccountVerification({ user: session.user, url, token }, ctx.request)); return ctx.json({ success: true, message: "Verification email sent" }); } if (!ctx.body.password && ctx.context.sessionConfig.freshAge !== 0) { const currentAge = new Date(session.session.createdAt).getTime(); const freshAge = ctx.context.sessionConfig.freshAge * 1e3; if (Date.now() - currentAge > freshAge * 1e3) throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.SESSION_EXPIRED }); } const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete; if (beforeDelete) await beforeDelete(session.user, ctx.request); await ctx.context.internalAdapter.deleteUser(session.user.id); await ctx.context.internalAdapter.deleteSessions(session.user.id); deleteSessionCookie(ctx); const afterDelete = ctx.context.options.user.deleteUser?.afterDelete; if (afterDelete) await afterDelete(session.user, ctx.request); return ctx.json({ success: true, message: "User deleted" }); }); const deleteUserCallback = createAuthEndpoint("/delete-user/callback", { method: "GET", query: z.object({ token: z.string().meta({ description: "The token to verify the deletion request" }), callbackURL: z.string().meta({ description: "The URL to redirect to after deletion" }).optional() }), use: [originCheck((ctx) => ctx.query.callbackURL)], metadata: { openapi: { description: "Callback to complete user deletion with verification token", responses: { "200": { description: "User successfully deleted", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean", description: "Indicates if the deletion was successful" }, message: { type: "string", enum: ["User deleted"], description: "Confirmation message" } }, required: ["success", "message"] } } } } } } } }, async (ctx) => { if (!ctx.context.options.user?.deleteUser?.enabled) { ctx.context.logger.error("Delete user is disabled. Enable it in the options"); throw new APIError("NOT_FOUND"); } const session = await getSessionFromCtx(ctx); if (!session) throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO }); const token = await ctx.context.internalAdapter.findVerificationValue(`delete-account-${ctx.query.token}`); if (!token || token.expiresAt < /* @__PURE__ */ new Date()) throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.INVALID_TOKEN }); if (token.value !== session.user.id) throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.INVALID_TOKEN }); const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete; if (beforeDelete) await beforeDelete(session.user, ctx.request); await ctx.context.internalAdapter.deleteUser(session.user.id); await ctx.context.internalAdapter.deleteSessions(session.user.id); await ctx.context.internalAdapter.deleteAccounts(session.user.id); await ctx.context.internalAdapter.deleteVerificationValue(token.id); deleteSessionCookie(ctx); const afterDelete = ctx.context.options.user.deleteUser?.afterDelete; if (afterDelete) await afterDelete(session.user, ctx.request); if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL || "/"); return ctx.json({ success: true, message: "User deleted" }); }); const changeEmail = createAuthEndpoint("/change-email", { method: "POST", body: z.object({ newEmail: z.email().meta({ description: "The new email address to set must be a valid email address" }), callbackURL: z.string().meta({ description: "The URL to redirect to after email verification" }).optional() }), use: [sensitiveSessionMiddleware], metadata: { openapi: { operationId: "changeEmail", responses: { "200": { description: "Email change request processed successfully", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", $ref: "#/components/schemas/User" }, status: { type: "boolean", description: "Indicates if the request was successful" }, message: { type: "string", enum: ["Email updated", "Verification email sent"], description: "Status message of the email change process", nullable: true } }, required: ["status"] } } } }, "422": { description: "Unprocessable Entity. Email already exists", content: { "application/json": { schema: { type: "object", properties: { message: { type: "string" } } } } } } } } } }, async (ctx) => { if (!ctx.context.options.user?.changeEmail?.enabled) { ctx.context.logger.error("Change email is disabled."); throw new APIError("BAD_REQUEST", { message: "Change email is disabled" }); } const newEmail = ctx.body.newEmail.toLowerCase(); if (newEmail === ctx.context.session.user.email) { ctx.context.logger.error("Email is the same"); throw new APIError("BAD_REQUEST", { message: "Email is the same" }); } if (await ctx.context.internalAdapter.findUserByEmail(newEmail)) { ctx.context.logger.error("Email already exists"); throw new APIError("UNPROCESSABLE_ENTITY", { message: BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL }); } /** * If the email is not verified, we can update the email if the option is enabled */ if (ctx.context.session.user.emailVerified !== true && ctx.context.options.user.changeEmail.updateEmailWithoutVerification) { await ctx.context.internalAdapter.updateUserByEmail(ctx.context.session.user.email, { email: newEmail }); await setSessionCookie(ctx, { session: ctx.context.session.session, user: { ...ctx.context.session.user, email: newEmail } }); if (ctx.context.options.emailVerification?.sendVerificationEmail) { const token$1 = await createEmailVerificationToken(ctx.context.secret, newEmail, void 0, ctx.context.options.emailVerification?.expiresIn); const url$1 = `${ctx.context.baseURL}/verify-email?token=${token$1}&callbackURL=${ctx.body.callbackURL || "/"}`; await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({ user: { ...ctx.context.session.user, email: newEmail }, url: url$1, token: token$1 }, ctx.request)); } return ctx.json({ status: true }); } if (ctx.context.session.user.emailVerified && (ctx.context.options.user.changeEmail.sendChangeEmailConfirmation || ctx.context.options.user.changeEmail.sendChangeEmailVerification)) { const token$1 = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-confirmation" }); const url$1 = `${ctx.context.baseURL}/verify-email?token=${token$1}&callbackURL=${ctx.body.callbackURL || "/"}`; const sendFn = ctx.context.options.user.changeEmail.sendChangeEmailConfirmation || ctx.context.options.user.changeEmail.sendChangeEmailVerification; if (sendFn) await ctx.context.runInBackgroundOrAwait(sendFn({ user: ctx.context.session.user, newEmail, url: url$1, token: token$1 }, ctx.request)); return ctx.json({ status: true }); } if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw new APIError("BAD_REQUEST", { message: "Verification email isn't enabled" }); } const token = await createEmailVerificationToken(ctx.context.secret, ctx.context.session.user.email, newEmail, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-verification" }); const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${ctx.body.callbackURL || "/"}`; await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({ user: { ...ctx.context.session.user, email: newEmail }, url, token }, ctx.request)); return ctx.json({ status: true }); }); //#endregion export { changeEmail, changePassword, deleteUser, deleteUserCallback, setPassword, updateUser }; //# sourceMappingURL=update-user.mjs.map