diff --git a/apps/docs/package.json b/apps/docs/package.json
index b7dff5d..6d1944b 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -31,6 +31,7 @@
"dependencies": {
"@better-auth-kit/legal-consent": "workspace:*",
"@better-auth-kit/waitlist": "workspace:*",
+ "@better-auth-kit/reverify": "workspace:*",
"@faker-js/faker": "^9.5.1",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-accordion": "^1.2.1",
diff --git a/apps/docs/src/app/components/email/page.tsx b/apps/docs/src/app/components/email/page.tsx
new file mode 100644
index 0000000..6468cad
--- /dev/null
+++ b/apps/docs/src/app/components/email/page.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { authClient } from "@/lib/auth-client";
+
+export default function Page() {
+ const session = authClient.useSession();
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/docs/src/lib/auth-client.ts b/apps/docs/src/lib/auth-client.ts
index bb3bf89..c71cc5b 100644
--- a/apps/docs/src/lib/auth-client.ts
+++ b/apps/docs/src/lib/auth-client.ts
@@ -5,7 +5,7 @@ import {
usernameClient,
} from "better-auth/client/plugins";
import { waitlistClient } from "@better-auth-kit/waitlist";
-import { nextCookies } from "better-auth/next-js";
+import { reverifyClient } from "@better-auth-kit/reverify/client";
export const authClient = createAuthClient({
plugins: [
@@ -13,5 +13,6 @@ export const authClient = createAuthClient({
apiKeyClient(),
usernameClient(),
emailOTPClient(),
+ reverifyClient(),
],
});
diff --git a/apps/docs/src/lib/auth.ts b/apps/docs/src/lib/auth.ts
index 2cd278b..3679dd3 100644
--- a/apps/docs/src/lib/auth.ts
+++ b/apps/docs/src/lib/auth.ts
@@ -3,9 +3,11 @@ import { apiKey, openAPI, organization, username } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import Database from "better-sqlite3";
import { legalConsent } from "@better-auth-kit/legal-consent";
+import { reverify } from "@better-auth-kit/reverify";
export const auth = betterAuth({
database: new Database("./test.db"),
+ trustedOrigins: ["http://localhost:3000"],
emailAndPassword: {
enabled: true,
},
@@ -25,27 +27,46 @@ export const auth = betterAuth({
// },
// },
// }),
+ reverify({
+ email: {
+ enabled: true,
+ sendReverificationEmail(params, ctx) {
+ if (params.type === "link") {
+ console.log(
+ `Send reverification link to ${params.user.email} with url ${params.url}`,
+ );
+ } else if (params.type === "otp") {
+ console.log(
+ `Send reverification otp to ${params.user.email} with code ${params.code}`,
+ );
+ }
+ },
+ },
+ }),
organization(),
openAPI(),
apiKey(),
username(),
nextCookies(),
],
+ emailVerification: {
+ sendVerificationEmail: async ({ user, url, token }, request) => {
+ console.log(
+ `Send verification email to ${user.email} with url ${url} and token ${token}`,
+ );
+ },
+ },
databaseHooks: {
user: {
create: {
after: async (user, ctx) => {
- //@ts-ignore - erroring because `getActiveOrganization` is not defined
- const organization = await getActiveOrganization(user.id);
- if (!organization) {
- auth.api.createOrganization({
- body: {
- name: "My Organization",
- slug: "my-organization",
- userId: user.id,
- },
- });
- }
+ // auth.api.createOrganization({
+ // body: {
+ // name: "My Organization",
+ // slug: "my-organization",
+ // userId: user.id,
+ // },
+ // });
},
},
},
diff --git a/bun.lock b/bun.lock
index a904f30..dd37df7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,6 +16,7 @@
"version": "0.0.0",
"dependencies": {
"@better-auth-kit/legal-consent": "workspace:*",
+ "@better-auth-kit/reverify": "workspace:*",
"@better-auth-kit/waitlist": "workspace:*",
"@faker-js/faker": "^9.5.1",
"@hookform/resolvers": "^4.1.3",
@@ -133,9 +134,19 @@
"@types/prompts": "^2.4.9",
},
},
+ "packages/internal/utils": {
+ "name": "@better-auth-kit/internal-utils",
+ "version": "0.0.1",
+ "dependencies": {
+ "bun-plugin-dts": "^0.3.0",
+ },
+ "devDependencies": {
+ "@types/bun": "1.2.4",
+ },
+ },
"packages/libraries/seed": {
"name": "@better-auth-kit/seed",
- "version": "1.0.2",
+ "version": "1.0.3",
"dependencies": {
"better-auth": "^1.2.4",
"chalk": "^5.4.1",
@@ -307,6 +318,8 @@
"@better-auth-kit/convex": ["@better-auth-kit/convex@workspace:packages/adapters/convex"],
+ "@better-auth-kit/internal-utils": ["@better-auth-kit/internal-utils@workspace:packages/internal/utils"],
+
"@better-auth-kit/legal-consent": ["@better-auth-kit/legal-consent@workspace:packages/plugins/legal-consent"],
"@better-auth-kit/reverify": ["@better-auth-kit/reverify@workspace:packages/plugins/reverify"],
diff --git a/package.json b/package.json
index deae8dd..83d67e5 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"packages/cli",
"packages/plugins/*",
"packages/libraries/*",
- "packages/adapters/*"
+ "packages/adapters/*",
+ "packages/internal/*"
],
"dependencies": {
"@daveyplate/better-auth-ui": "^1.2.19"
diff --git a/packages/plugins/reverify/package.json b/packages/plugins/reverify/package.json
index e10daab..83fdd0d 100644
--- a/packages/plugins/reverify/package.json
+++ b/packages/plugins/reverify/package.json
@@ -18,7 +18,7 @@
}
},
"scripts": {
- "dev": "bun build --entrypoints ./src/index.ts --outdir ./dist --target node --format esm --sourcemap=external --minify --packages external --watch",
+ "dev": "bun build --entrypoints ./src/index.ts ./src/client.ts --outdir ./dist --target node --format esm --sourcemap=external --minify --packages external --watch",
"build": "bun build.ts",
"test": "vitest"
},
diff --git a/packages/plugins/reverify/src/client.ts b/packages/plugins/reverify/src/client.ts
index e112aa3..5d1a340 100644
--- a/packages/plugins/reverify/src/client.ts
+++ b/packages/plugins/reverify/src/client.ts
@@ -3,12 +3,15 @@ import type { reverify } from "./index";
type ReverifyPlugin = typeof reverify;
-export const reverifyClientPlugin = () => {
+export const reverifyClient = () => {
return {
id: "reverify",
$InferServerPlugin: {} as ReturnType,
pathMethods: {
"/reverify/password": "POST",
+ "/reverify/send-email": "POST",
+ "/reverify/verify-email-otp": "POST",
+ "/reverify/is-verified": "GET",
},
} satisfies BetterAuthClientPlugin;
};
diff --git a/packages/plugins/reverify/src/index.ts b/packages/plugins/reverify/src/index.ts
index d9768b9..1e4555b 100644
--- a/packages/plugins/reverify/src/index.ts
+++ b/packages/plugins/reverify/src/index.ts
@@ -1,56 +1,32 @@
import type { BetterAuthPlugin } from "better-auth";
-import {
- APIError,
- createAuthEndpoint,
- sessionMiddleware,
-} from "better-auth/api";
-import { z } from "zod";
+import { reverifyPassword } from "./routes/password";
+import { reverifyEmail } from "./routes/email";
+import { isVerified } from "./routes/isVerified";
+import { emailreverification } from "./schema";
+import { mergeSchema } from "better-auth/db";
+import type { ReverifyOptions } from "./types";
+export * from "./types";
export const ERROR_CODES = {
NO_SESSION: "No session found.",
} as const;
-export const reverify = () => {
+export const reverify = (
+ options: ReverifyOptions = {},
+) => {
+ const { email } = options;
+
return {
id: "reverify",
endpoints: {
- reverifyPassword: createAuthEndpoint(
- "/reverify/password",
- {
- method: "POST",
- use: [sessionMiddleware],
- body: z.object({
- password: z.string(),
- }),
- },
- async (ctx) => {
- const session = ctx.context.session;
-
- if (!session) {
- throw new APIError("UNAUTHORIZED", {
- message: ERROR_CODES.NO_SESSION,
- });
- }
- let validPassword = false;
- try {
- validPassword = await ctx.context.password.checkPassword(
- session.user.id,
- ctx,
- );
- } catch (error: unknown) {
- console.error(error);
- if (
- error instanceof APIError &&
- error?.body?.code === "INVALID_PASSWORD"
- ) {
- return ctx.json({ valid: false });
- }
- throw error;
- }
-
- return ctx.json({ valid: validPassword });
- },
- ),
+ reverifyPassword,
+ isVerified,
+ ...reverifyEmail({ email }),
+ },
+ schema: {
+ ...(email?.enabled
+ ? mergeSchema(emailreverification, options?.email?.schema)
+ : {}),
},
} satisfies BetterAuthPlugin;
};
diff --git a/packages/plugins/reverify/src/routes/email.ts b/packages/plugins/reverify/src/routes/email.ts
new file mode 100644
index 0000000..7a3f4df
--- /dev/null
+++ b/packages/plugins/reverify/src/routes/email.ts
@@ -0,0 +1,165 @@
+import {
+ APIError,
+ createAuthEndpoint,
+ sessionMiddleware,
+} from "better-auth/api";
+import {
+ ERROR_CODES,
+ type EmailReverification,
+ type ReverifyOptions,
+} from "../index";
+import { z } from "zod";
+import {
+ generateId,
+ type AuthContext,
+ type EndpointContext,
+ type Middleware,
+} from "better-auth";
+
+const reverifyEmailBodySchema = z.object({
+ type: z.enum(["link", "otp"]).optional(),
+});
+
+export type ReverifyEmailEndpointContext = EndpointContext<
+ "/reverify/send-email",
+ {
+ method: "POST";
+ use: ((inputContext: {
+ body?: any;
+ query?: Record | undefined;
+ request?: Request | undefined;
+ headers?: Headers | undefined;
+ asResponse?: boolean | undefined;
+ returnHeaders?: boolean | undefined;
+ use?: Middleware[] | undefined;
+ }) => Promise<{
+ session: {
+ session: Record & {
+ id: string;
+ createdAt: Date;
+ updatedAt: Date;
+ userId: string;
+ expiresAt: Date;
+ token: string;
+ ipAddress?: string | null | undefined;
+ userAgent?: string | null | undefined;
+ };
+ user: Record & {
+ id: string;
+ name: string;
+ email: string;
+ emailVerified: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ image?: string | null | undefined;
+ };
+ };
+ }>)[];
+ body?: typeof reverifyEmailBodySchema;
+ },
+ AuthContext
+>;
+
+export const reverifyEmail = (opts: {
+ email: ReverifyOptions["email"];
+}) => {
+ const { email } = opts;
+ const {
+ enabled = false,
+ disableMagicLink = false,
+ disableOTP = false,
+ sendReverificationEmail,
+ } = email ?? {};
+
+ return {
+ sendReverificationEmail: createAuthEndpoint(
+ "/reverify/send-email",
+ {
+ method: "POST",
+ use: [sessionMiddleware],
+ body: reverifyEmailBodySchema,
+ },
+ async (ctx) => {
+ if (!enabled) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Reverification email isn't enabled",
+ });
+ }
+ const { type = disableOTP ? "link" : "otp" } = ctx.body ?? {};
+ if (type === "link" && disableMagicLink) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Magic link reverification isn't enabled",
+ });
+ }
+ if (type === "otp" && disableOTP) {
+ throw new APIError("BAD_REQUEST", {
+ message: "Email OTP reverification isn't enabled",
+ });
+ }
+
+ const session = ctx.context.session;
+
+ if (!session) {
+ throw new APIError("UNAUTHORIZED", {
+ message: ERROR_CODES.NO_SESSION,
+ });
+ }
+ if (!sendReverificationEmail) {
+ ctx.context.logger.error(
+ "[Reverify] send reverification function isn't provided.",
+ );
+ throw new APIError("NOT_IMPLEMENTED", {
+ message: "Reverification email isn't fully implemented",
+ });
+ }
+ if (type === "otp") {
+ const code = generateOTPCode();
+ await sendReverificationEmail(
+ {
+ code,
+ type: "otp",
+ user: session.user,
+ },
+ ctx,
+ );
+
+ await ctx.context.adapter.create({
+ model:
+ opts.email?.schema?.emailreverification?.modelName ??
+ "emailreverification",
+ data: {
+ id: generateId(),
+ createdAt: new Date(),
+ type: "otp",
+ userId: session.user.id,
+ value: code,
+ },
+ });
+
+ return ctx.json({
+ status: true,
+ });
+ }
+ },
+ ),
+ verifyEmailOTP: createAuthEndpoint(
+ "/reverify/verify-email-otp",
+ {
+ method: "POST",
+ body: z.object({
+ code: z.string(),
+ email: z.string(),
+ }),
+ },
+ async (ctx) => {
+ // await ctx.context.internalAdapter.updateSession(ctx.context.session.session.token, {
+ // createdAt: new Date()
+ // });
+ },
+ ),
+ };
+};
+
+function generateOTPCode() {
+ return Math.floor(100000 + Math.random() * 900000).toString();
+}
diff --git a/packages/plugins/reverify/src/routes/isVerified.ts b/packages/plugins/reverify/src/routes/isVerified.ts
new file mode 100644
index 0000000..91fb1b0
--- /dev/null
+++ b/packages/plugins/reverify/src/routes/isVerified.ts
@@ -0,0 +1,45 @@
+import {
+ APIError,
+ createAuthEndpoint,
+ sessionMiddleware,
+} from "better-auth/api";
+import { ERROR_CODES } from "../index";
+export const isVerified = createAuthEndpoint(
+ "/reverify/is-verified",
+ {
+ method: "GET",
+ use: [sessionMiddleware],
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+
+ if (!session) {
+ throw new APIError("UNAUTHORIZED", {
+ message: ERROR_CODES.NO_SESSION,
+ });
+ }
+
+ const freshAge = ctx.context.options.session?.freshAge ?? 60 * 60 * 24;
+
+ if (freshAge === 0) {
+ return ctx.json({
+ verified: true,
+ });
+ }
+
+ const createdAt = session.session.createdAt;
+ const current = new Date();
+ const diff = current.getTime() - createdAt.getTime();
+ const diffInMinutes = diff / (1000 * 60);
+
+ if (diffInMinutes > freshAge) {
+ return ctx.json({
+ verified: false,
+ });
+ }
+
+ return ctx.json({
+ verified: true,
+ });
+ },
+);
diff --git a/packages/plugins/reverify/src/routes/password.ts b/packages/plugins/reverify/src/routes/password.ts
new file mode 100644
index 0000000..7ea78bd
--- /dev/null
+++ b/packages/plugins/reverify/src/routes/password.ts
@@ -0,0 +1,45 @@
+import {
+ APIError,
+ createAuthEndpoint,
+ sessionMiddleware,
+} from "better-auth/api";
+import { ERROR_CODES } from "../index";
+import { z } from "zod";
+
+export const reverifyPassword = createAuthEndpoint(
+ "/reverify/password",
+ {
+ method: "POST",
+ use: [sessionMiddleware],
+ body: z.object({
+ password: z.string(),
+ }),
+ },
+ async (ctx) => {
+ const session = ctx.context.session;
+
+ if (!session) {
+ throw new APIError("UNAUTHORIZED", {
+ message: ERROR_CODES.NO_SESSION,
+ });
+ }
+ let validPassword = false;
+ try {
+ validPassword = await ctx.context.password.checkPassword(
+ session.user.id,
+ ctx,
+ );
+ } catch (error: unknown) {
+ console.error(error);
+ if (
+ error instanceof APIError &&
+ error?.body?.code === "INVALID_PASSWORD"
+ ) {
+ return ctx.json({ valid: false });
+ }
+ throw error;
+ }
+
+ return ctx.json({ valid: validPassword });
+ },
+);
diff --git a/packages/plugins/reverify/src/schema.ts b/packages/plugins/reverify/src/schema.ts
new file mode 100644
index 0000000..f2011f1
--- /dev/null
+++ b/packages/plugins/reverify/src/schema.ts
@@ -0,0 +1,35 @@
+import type { AuthPluginSchema } from "better-auth";
+import { z } from "zod";
+
+export const emailreverification = {
+ emailreverification: {
+ fields: {
+ userId: {
+ type: "string",
+ references: {
+ field: "id",
+ model: "user",
+ onDelete: "cascade",
+ },
+ required: true,
+ },
+ createdAt: {
+ type: "date",
+ required: true,
+ defaultValue: () => new Date(),
+ },
+ type: {
+ type: "string",
+ required: true,
+ validator: {
+ input: z.enum(["link", "otp"]),
+ output: z.enum(["link", "otp"]),
+ },
+ },
+ value: {
+ type: "string",
+ required: true,
+ },
+ },
+ },
+} satisfies AuthPluginSchema;
diff --git a/packages/plugins/reverify/src/types.ts b/packages/plugins/reverify/src/types.ts
new file mode 100644
index 0000000..a97370b
--- /dev/null
+++ b/packages/plugins/reverify/src/types.ts
@@ -0,0 +1,66 @@
+import type { InferOptionSchema, User } from "better-auth";
+import type { ReverifyEmailEndpointContext } from "./routes/email";
+import type { emailreverification } from "./schema";
+
+type ReverifyType = "link" | "otp";
+
+interface ReverifyEmailParamsBase {
+ user: User;
+ type: ReverifyType;
+}
+
+interface ReverifyLinkEmailParams extends ReverifyEmailParamsBase {
+ type: "link";
+ url: string;
+}
+
+interface ReverifyOTPEmailParams extends ReverifyEmailParamsBase {
+ type: "otp";
+ code: string;
+}
+
+export type ReverifyEmailParams =
+ | ReverifyLinkEmailParams
+ | ReverifyOTPEmailParams;
+
+export interface ReverifyOptions {
+ email?: {
+ /**
+ * Whether to enable email reverification.
+ *
+ * @default false
+ */
+ enabled: EmailEnabled;
+ /**
+ * Optionally disable magic link reverification.
+ *
+ * @default false
+ */
+ disableMagicLink?: boolean;
+ /**
+ * Optionally disable OTP reverification.
+ *
+ * @default false
+ */
+ disableOTP?: boolean;
+ /**
+ * Callback to send a reverification email.
+ */
+ sendReverificationEmail?: (
+ params: ReverifyEmailParams,
+ ctx: ReverifyEmailEndpointContext,
+ ) => void | Promise;
+ /**
+ * Custom schema for the email reverification table.
+ */
+ schema?: InferOptionSchema;
+ };
+}
+
+export interface EmailReverification {
+ id: string;
+ userId: string;
+ createdAt: Date;
+ type: ReverifyType;
+ value: string;
+}
diff --git a/packages/plugins/reverify/tests/reverify.test.ts b/packages/plugins/reverify/tests/reverify.test.ts
index 03a153e..a169204 100644
--- a/packages/plugins/reverify/tests/reverify.test.ts
+++ b/packages/plugins/reverify/tests/reverify.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { getTestInstance } from "@better-auth-kit/tests";
import { reverify } from "../src/index";
-import { reverifyClientPlugin } from "../src/client";
+import { reverifyClient } from "../src/client";
describe("reverify plugin", async () => {
const { auth, client, testUser, signInWithTestUser } = await getTestInstance(
@@ -10,7 +10,7 @@ describe("reverify plugin", async () => {
},
{
clientOptions: {
- plugins: [reverifyClientPlugin()],
+ plugins: [reverifyClient()],
},
},
);