UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

323 lines (322 loc) • 12.2 kB
import { originCheck } from "../middlewares/origin-check.mjs"; import { signJWT } from "../../crypto/jwt.mjs"; import { parseUserOutput } from "../../db/schema.mjs"; import { setSessionCookie } from "../../cookies/index.mjs"; import { getSessionFromCtx } from "./session.mjs"; import { safeCloneRequest } from "../../utils/request.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { createAuthEndpoint } from "@better-auth/core/api"; import * as z from "zod"; import { jwtVerify } from "jose"; import { JWTExpired } from "jose/errors"; //#region src/api/routes/email-verification.ts async function createEmailVerificationToken(secret, email, updateTo, expiresIn = 3600, extraPayload) { return await signJWT({ email: email.toLowerCase(), updateTo: updateTo?.toLowerCase(), ...extraPayload }, secret, expiresIn); } /** * A function to send a verification email to the user */ async function sendVerificationEmailFn(ctx, user) { if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.VERIFICATION_EMAIL_NOT_ENABLED); } const token = await createEmailVerificationToken(ctx.context.secret, user.email, void 0, ctx.context.options.emailVerification?.expiresIn); const callbackURL = ctx.body.callbackURL ? encodeURIComponent(ctx.body.callbackURL) : encodeURIComponent("/"); const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`; await ctx.context.options.emailVerification.sendVerificationEmail({ user, url, token }, ctx.request); } const sendVerificationEmail = createAuthEndpoint("/send-verification-email", { method: "POST", operationId: "sendVerificationEmail", cloneRequest: true, body: z.object({ email: z.email().meta({ description: "The email to send the verification email to" }), callbackURL: z.string().meta({ description: "The URL to use for email verification callback" }).optional() }), metadata: { openapi: { operationId: "sendVerificationEmail", description: "Send a verification email to the user", requestBody: { content: { "application/json": { schema: { type: "object", properties: { email: { type: "string", description: "The email to send the verification email to", example: "user@example.com" }, callbackURL: { type: "string", description: "The URL to use for email verification callback", example: "https://example.com/callback", nullable: true } }, required: ["email"] } } } }, responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", description: "Indicates if the email was sent successfully", example: true } } } } } }, "400": { description: "Bad Request", content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", description: "Error message", example: "Verification email isn't enabled" } } } } } } } } } }, async (ctx) => { if (!ctx.context.options.emailVerification?.sendVerificationEmail) { ctx.context.logger.error("Verification email isn't enabled."); throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.VERIFICATION_EMAIL_NOT_ENABLED); } const { email } = ctx.body; const session = await getSessionFromCtx(ctx); if (!session) { /** * Enforce a constant-time floor so an attacker cannot distinguish * "email not found / already verified" (fast local JWT sign) from * "email found and unverified" (slow external email-send) by * comparing response times. */ const MINIMUM_MS = 500; const start = Date.now(); const user = await ctx.context.internalAdapter.findUserByEmail(email); let error; if (!user || user.user.emailVerified) await createEmailVerificationToken(ctx.context.secret, email, void 0, ctx.context.options.emailVerification?.expiresIn); else try { await sendVerificationEmailFn(ctx, user.user); } catch (e) { error = e; } const remaining = MINIMUM_MS - (Date.now() - start); if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining)); if (error) throw error; return ctx.json({ status: true }); } if (session?.user.email.toLowerCase() !== email.toLowerCase()) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.EMAIL_MISMATCH); if (session?.user.emailVerified) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.EMAIL_ALREADY_VERIFIED); await sendVerificationEmailFn(ctx, session.user); return ctx.json({ status: true }); }); const verifyEmail = createAuthEndpoint("/verify-email", { method: "GET", operationId: "verifyEmail", query: z.object({ token: z.string().meta({ description: "The token to verify the email" }), callbackURL: z.string().meta({ description: "The URL to redirect to after email verification" }).optional() }), use: [originCheck((ctx) => ctx.query.callbackURL)], metadata: { openapi: { description: "Verify the email of the user", parameters: [{ name: "token", in: "query", description: "The token to verify the email", required: true, schema: { type: "string" } }, { name: "callbackURL", in: "query", description: "The URL to redirect to after email verification", required: false, schema: { type: "string" } }], responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", $ref: "#/components/schemas/User" }, status: { type: "boolean", description: "Indicates if the email was verified successfully" } }, required: ["user", "status"] } } } } } } } }, async (ctx) => { function redirectOnError(error) { if (ctx.query.callbackURL) { if (ctx.query.callbackURL.includes("?")) throw ctx.redirect(`${ctx.query.callbackURL}&error=${error.code}`); throw ctx.redirect(`${ctx.query.callbackURL}?error=${error.code}`); } throw APIError.from("UNAUTHORIZED", error); } const { token } = ctx.query; let jwt; try { jwt = await jwtVerify(token, new TextEncoder().encode(ctx.context.secret), { algorithms: ["HS256"] }); } catch (e) { if (e instanceof JWTExpired) return redirectOnError(BASE_ERROR_CODES.TOKEN_EXPIRED); return redirectOnError(BASE_ERROR_CODES.INVALID_TOKEN); } const parsed = z.object({ email: z.email(), updateTo: z.string().optional(), requestType: z.string().optional() }).parse(jwt.payload); const user = await ctx.context.internalAdapter.findUserByEmail(parsed.email); if (!user) return redirectOnError(BASE_ERROR_CODES.USER_NOT_FOUND); if (parsed.updateTo) { const session = await getSessionFromCtx(ctx); if (session && session.user.email !== parsed.email) return redirectOnError(BASE_ERROR_CODES.INVALID_USER); switch (parsed.requestType) { /** * User clicks confirmation -> sends verification to new email */ case "change-email-confirmation": { const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.email, parsed.updateTo, ctx.context.options.emailVerification?.expiresIn, { requestType: "change-email-verification" }); const updateCallbackURL = ctx.query.callbackURL ? encodeURIComponent(ctx.query.callbackURL) : encodeURIComponent("/"); const url = `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`; if (ctx.context.options.emailVerification?.sendVerificationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({ user: { ...user.user, email: parsed.updateTo }, url, token: newToken }, safeCloneRequest(ctx.request))); if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL); return ctx.json({ status: true }); } /** * User clicks verification -> updates email */ case "change-email-verification": { let activeSession = session; if (!activeSession) { const newSession = await ctx.context.internalAdapter.createSession(user.user.id); if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION); activeSession = { session: newSession, user: user.user }; } const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(parsed.email, { email: parsed.updateTo, emailVerified: true }); if (ctx.context.options.emailVerification?.afterEmailVerification) await ctx.context.options.emailVerification.afterEmailVerification(updatedUser, ctx.request); await setSessionCookie(ctx, { session: activeSession.session, user: { ...activeSession.user, email: parsed.updateTo, emailVerified: true } }); if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL); return ctx.json({ status: true, user: parseUserOutput(ctx.context.options, updatedUser) }); } /** * Legacy flow * * - skips two-step verification * - updates email immediately */ default: { let activeSession = session; if (!activeSession) { const newSession = await ctx.context.internalAdapter.createSession(user.user.id); if (!newSession) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION); activeSession = { session: newSession, user: user.user }; } const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(parsed.email, { email: parsed.updateTo, emailVerified: false }); const newToken = await createEmailVerificationToken(ctx.context.secret, parsed.updateTo); const updateCallbackURL = ctx.query.callbackURL ? encodeURIComponent(ctx.query.callbackURL) : encodeURIComponent("/"); if (ctx.context.options.emailVerification?.sendVerificationEmail) await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({ user: updatedUser, url: `${ctx.context.baseURL}/verify-email?token=${newToken}&callbackURL=${updateCallbackURL}`, token: newToken }, safeCloneRequest(ctx.request))); await setSessionCookie(ctx, { session: activeSession.session, user: { ...activeSession.user, email: parsed.updateTo, emailVerified: false } }); if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL); return ctx.json({ status: true, user: parseUserOutput(ctx.context.options, updatedUser) }); } } } if (user.user.emailVerified) { if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL); return ctx.json({ status: true, user: null }); } if (ctx.context.options.emailVerification?.beforeEmailVerification) await ctx.context.options.emailVerification.beforeEmailVerification(user.user, ctx.request); const updatedUser = await ctx.context.internalAdapter.updateUserByEmail(parsed.email, { emailVerified: true }); if (ctx.context.options.emailVerification?.afterEmailVerification) await ctx.context.options.emailVerification.afterEmailVerification(updatedUser, ctx.request); if (ctx.context.options.emailVerification?.autoSignInAfterVerification) { const currentSession = await getSessionFromCtx(ctx); if (!currentSession || currentSession.user.email !== parsed.email) { const session = await ctx.context.internalAdapter.createSession(user.user.id); if (!session) throw APIError.from("INTERNAL_SERVER_ERROR", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION); await setSessionCookie(ctx, { session, user: { ...user.user, emailVerified: true } }); } else await setSessionCookie(ctx, { session: currentSession.session, user: { ...currentSession.user, emailVerified: true } }); } if (ctx.query.callbackURL) throw ctx.redirect(ctx.query.callbackURL); return ctx.json({ status: true, user: null }); }); //#endregion export { createEmailVerificationToken, sendVerificationEmail, sendVerificationEmailFn, verifyEmail };