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()], }, }, );