diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index c3aa9aba07..36c2003d4a 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -65,6 +65,11 @@ List of all the available options for configuring Better Auth. description: "Send reset password token to user's email.", type: '(token: string, user: User) => Promise', }, + resetPasswordTokenExpiresIn: { + description: "Expiration time for the reset password token. The value should be in seconds.", + type: 'number', + default: 60 * 60 * 1 // 1 hour + }, sendVerificationEmail: { description: "Send verification email to user's email. It takes email and data that contains `url` and `token` props.", type: `(email: string, data: Data) => Promise` diff --git a/packages/better-auth/src/api/routes/forget-password.test.ts b/packages/better-auth/src/api/routes/forget-password.test.ts index 410cde4b6f..a283da58f2 100644 --- a/packages/better-auth/src/api/routes/forget-password.test.ts +++ b/packages/better-auth/src/api/routes/forget-password.test.ts @@ -8,7 +8,7 @@ describe("forget password", async (it) => { emailAndPassword: { enabled: true, async sendResetPassword(url, user) { - token = url.split("/").pop() || ""; + token = url.split("?")[0].split("/").pop() || ""; await mockSendEmail(); }, }, @@ -29,10 +29,11 @@ describe("forget password", async (it) => { }, { query: { - currentURL: `http://localhost:3000/reset-password?token=${token}`, + token, }, }, ); + expect(res.data).toMatchObject({ status: true, }); @@ -50,4 +51,20 @@ describe("forget password", async (it) => { }); expect(newCred.data?.session).toBeDefined(); }); + + it("shouldn't allow the token to be used twice", async () => { + const newPassword = "new-password"; + const res = await client.resetPassword( + { + newPassword, + }, + { + query: { + token, + }, + }, + ); + + expect(res.error?.status).toBe(400); + }); }); diff --git a/packages/better-auth/src/api/routes/forget-password.ts b/packages/better-auth/src/api/routes/forget-password.ts index c0ec38de22..63df2fa149 100644 --- a/packages/better-auth/src/api/routes/forget-password.ts +++ b/packages/better-auth/src/api/routes/forget-password.ts @@ -1,6 +1,3 @@ -import { TimeSpan } from "oslo"; -import { createJWT, parseJWT, type JWT } from "oslo/jwt"; -import { validateJWT } from "oslo/jwt"; import { z } from "zod"; import { createAuthEndpoint } from "../call"; import { APIError } from "better-call"; @@ -54,22 +51,20 @@ export const forgetPassword = createAuthEndpoint( }, ); } - const token = await createJWT( - "HS256", - Buffer.from(ctx.context.secret), - { - email: user.user.email, - redirectTo: redirectTo, - }, - { - expiresIn: new TimeSpan(1, "h"), - issuer: "better-auth", - subject: "forget-password", - audiences: [user.user.email], - includeIssuedTimestamp: true, - }, + const defaultExpiresIn = 60 * 60 * 1; + const expiresAt = new Date( + Date.now() + + 1000 * + (ctx.context.options.emailAndPassword.resetPasswordTokenExpiresIn || + defaultExpiresIn), ); - const url = `${ctx.context.baseURL}/reset-password/${token}`; + const verificationToken = ctx.context.uuid(); + await ctx.context.internalAdapter.createVerificationValue({ + value: user.user.id, + identifier: `reset-password:${verificationToken}`, + expiresAt, + }); + const url = `${ctx.context.baseURL}/reset-password/${verificationToken}?callbackURL=${redirectTo}`; await ctx.context.options.emailAndPassword.sendResetPassword( url, user.user, @@ -84,33 +79,27 @@ export const forgetPasswordCallback = createAuthEndpoint( "/reset-password/:token", { method: "GET", + query: z.object({ + callbackURL: z.string(), + }), + use: [redirectURLMiddleware], }, async (ctx) => { const { token } = ctx.params; - let decodedToken: JWT; - const schema = z.object({ - email: z.string(), - redirectTo: z.string(), - }); - try { - decodedToken = await validateJWT( - "HS256", - Buffer.from(ctx.context.secret), - token, - ); - if (!decodedToken.expiresAt || decodedToken.expiresAt < new Date()) { - throw Error("Token expired"); - } - } catch (e) { - const decoded = parseJWT(token); - const jwt = schema.safeParse(decoded?.payload); - if (jwt.success) { - throw ctx.redirect(`${jwt.data?.redirectTo}?error=invalid_token`); - } else { - throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_token`); - } + const callbackURL = ctx.query.callbackURL; + const redirectTo = callbackURL.startsWith("http") + ? callbackURL + : `${ctx.context.options.baseURL}${callbackURL}`; + if (!token || !callbackURL) { + throw ctx.redirect(`${ctx.context.baseURL}/error?error=INVALID_TOKEN`); + } + const verification = + await ctx.context.internalAdapter.findVerificationValue( + `reset-password:${token}`, + ); + if (!verification || verification.expiresAt < new Date()) { + throw ctx.redirect(`${redirectTo}?error=INVALID_TOKEN`); } - const { redirectTo } = schema.parse(decodedToken.payload); throw ctx.redirect(`${redirectTo}?token=${token}`); }, ); @@ -118,92 +107,60 @@ export const forgetPasswordCallback = createAuthEndpoint( export const resetPassword = createAuthEndpoint( "/reset-password", { - method: "POST", query: z .object({ - currentURL: z.string(), + token: z.string(), }) .optional(), + method: "POST", body: z.object({ newPassword: z.string(), - callbackURL: z.string().optional(), }), }, async (ctx) => { - const token = ctx.query?.currentURL.split("?token=")[1]; + const token = ctx.query?.token; if (!token) { throw new APIError("BAD_REQUEST", { message: "Token not found", }); } const { newPassword } = ctx.body; - try { - const jwt = await validateJWT( - "HS256", - Buffer.from(ctx.context.secret), - token, - ); - const email = z - .string() - .email() - .parse((jwt.payload as { email: string }).email); - const user = await ctx.context.internalAdapter.findUserByEmail(email); - if (!user) { - return ctx.json( - { - error: "User not found", - data: null, - }, - { - status: 400, - body: { - message: "failed to reset password", - }, - }, - ); - } - if ( - newPassword.length < - (ctx.context.options.emailAndPassword?.minPasswordLength || 8) || - newPassword.length > - (ctx.context.options.emailAndPassword?.maxPasswordLength || 32) - ) { - throw new APIError("BAD_REQUEST", { - message: "Password is too short or too long", - }); - } - const hashedPassword = await ctx.context.password.hash(newPassword); - const updatedUser = await ctx.context.internalAdapter.updatePassword( - user.user.id, - hashedPassword, - ); - if (!updatedUser) { - throw new APIError("BAD_REQUEST", { - message: "Failed to update password", - }); - } - return ctx.json( - { - error: null, - data: { - status: true, - url: ctx.body.callbackURL, - redirect: !!ctx.body.callbackURL, - }, - }, - { - body: { - status: true, - url: ctx.body.callbackURL, - redirect: !!ctx.body.callbackURL, - }, - }, - ); - } catch (e) { - ctx.context.logger.error("Failed to reset password", e); + const id = `reset-password:${token}`; + const verification = + await ctx.context.internalAdapter.findVerificationValue(id); + + if (!verification || verification.expiresAt < new Date()) { throw new APIError("BAD_REQUEST", { - message: "Failed to reset password", + message: "Invalid token", }); } + await ctx.context.internalAdapter.deleteVerificationValue(verification.id); + const userId = verification.value; + const hashedPassword = await ctx.context.password.hash(newPassword); + const accounts = await ctx.context.internalAdapter.findAccounts(userId); + const account = accounts.find((ac) => ac.providerId === "credential"); + if (!account) { + await ctx.context.internalAdapter.createAccount({ + userId, + providerId: "credential", + password: hashedPassword, + accountId: ctx.context.uuid(), + }); + return ctx.json({ + status: true, + }); + } + const updatedUser = await ctx.context.internalAdapter.updatePassword( + userId, + hashedPassword, + ); + if (!updatedUser) { + throw new APIError("BAD_REQUEST", { + message: "Failed to update password", + }); + } + return ctx.json({ + status: true, + }); }, ); diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index 05afd91596..6264189735 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -49,6 +49,22 @@ export const createInternalAdapter = ( ); return createdUser as T & User; }, + createAccount: async ( + account: Omit & + Partial & + Record, + ) => { + const createdAccount = await createWithHooks( + { + id: generateId(), + createdAt: new Date(), + updatedAt: new Date(), + ...account, + }, + "account", + ); + return createdAccount as T & Account; + }, listSessions: async (userId: string) => { const sessions = await adapter.findMany({ model: tables.session.tableName, diff --git a/packages/better-auth/src/init.ts b/packages/better-auth/src/init.ts index 6ccdcfcdae..7f4549385c 100644 --- a/packages/better-auth/src/init.ts +++ b/packages/better-auth/src/init.ts @@ -22,6 +22,7 @@ import { createLogger, logger } from "./utils/logger"; import { socialProviderList, socialProviders } from "./social-providers"; import { BetterAuthError } from "./error"; import type { OAuthProvider } from "./oauth2"; +import { generateId } from "./utils"; export const init = async (options: BetterAuthOptions) => { const adapter = await getAdapter(options); @@ -104,6 +105,7 @@ export const init = async (options: BetterAuthOptions) => { disabled: options.logger?.disabled || false, }), db, + uuid: generateId, secondaryStorage: options.secondaryStorage, password: { hash: options.emailAndPassword?.password?.hash || hashPassword, @@ -147,6 +149,7 @@ export type AuthContext = { updateAge: number; expiresIn: number; }; + uuid: (size?: number) => string; secondaryStorage: SecondaryStorage | undefined; password: { hash: (password: string) => Promise; diff --git a/packages/better-auth/src/plugins/admin/index.ts b/packages/better-auth/src/plugins/admin/index.ts index 0457b8edc2..0236870e36 100644 --- a/packages/better-auth/src/plugins/admin/index.ts +++ b/packages/better-auth/src/plugins/admin/index.ts @@ -171,7 +171,7 @@ export const admin = (options?: AdminOptions) => { email: z.string(), password: z.string(), name: z.string(), - role: z.enum(["user", "admin"]), + role: z.string(), /** * extra fields for user */ diff --git a/packages/better-auth/src/types/options.ts b/packages/better-auth/src/types/options.ts index 74c36634cc..fedfb82545 100644 --- a/packages/better-auth/src/types/options.ts +++ b/packages/better-auth/src/types/options.ts @@ -114,6 +114,11 @@ export interface BetterAuthOptions { * send reset password email */ sendResetPassword?: (url: string, user: User) => Promise; + /** + * Number of seconds the reset password token is valid for. + * @default 1 hour + */ + resetPasswordTokenExpiresIn?: number; /** * @param user the user to send the verification email to * @param url the url to send the verification email to