better-auth
Version:
The most comprehensive authentication framework for TypeScript.
323 lines (322 loc) • 12.2 kB
JavaScript
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 };