diff --git a/.changeset/clean-dogs-protect.md b/.changeset/clean-dogs-protect.md new file mode 100644 index 0000000000..546e9e494d --- /dev/null +++ b/.changeset/clean-dogs-protect.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Support Vercel BotID checks on protected authentication routes in Vercel-hosted applications. diff --git a/.cspell/third-party.txt b/.cspell/third-party.txt index b9a2d3a4b0..3e1d44b62d 100644 --- a/.cspell/third-party.txt +++ b/.cspell/third-party.txt @@ -49,3 +49,4 @@ elif zitadel anthropics nextjs +botid diff --git a/docs/content/docs/plugins/captcha.mdx b/docs/content/docs/plugins/captcha.mdx index 4d74ff17fb..ef2ba5f558 100644 --- a/docs/content/docs/plugins/captcha.mdx +++ b/docs/content/docs/plugins/captcha.mdx @@ -10,14 +10,25 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by * [Cloudflare Turnstile](https://www.cloudflare.com/application-services/products/turnstile/) * [hCaptcha](https://www.hcaptcha.com/) * [CaptchaFox](https://captchafox.com/) +* [Vercel BotID](https://vercel.com/docs/botid) - This plugin works out of the box with Email & Password authentication. To use it with other authentication methods, you will need to configure the endpoints array in the plugin options. + The default endpoints cover Email & Password authentication. Other authentication methods need an explicit endpoints array. Vercel BotID also needs the client-side setup below. ## Installation + + ### Install dependencies (Optional) + + If you are using **Vercel BotID**, install its SDK in your application: + + ```package-install + npm install botid + ``` + + ### Add the plugin to your **auth** config @@ -37,13 +48,13 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by - ### Add the captcha token to your request headers + ### Send the token for token-based providers The `x-captcha-user-remote-ip` header is no longer required—IP is now auto-detected server-side. - Add the captcha token to your request headers for all protected endpoints. This example shows how to include it in a `signIn` request: + For token-based providers, add the captcha token to requests for protected endpoints. This example shows a `signIn` request: ```ts import { authClient } from "@/lib/auth-client" @@ -66,30 +77,89 @@ The **Captcha Plugin** integrates bot protection into your Better Auth system by +## Providers + +### Vercel BotID + +BotID requires a Vercel deployment and client-side setup. Follow [Vercel's setup guide](https://vercel.com/docs/botid/get-started) for the rewrites and client integration appropriate to your framework. The Better Auth plugin does not use a `secretKey` or an `x-captcha-response` header: + +```ts title="auth.ts" +import { checkBotId } from "botid/server"; +import { betterAuth } from "better-auth"; +import { captcha } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + captcha({ + provider: "vercel-botid", + checkBotId, + }), + ], +}); +``` + +When using `initBotId`, protect Better Auth's default auth routes at their actual request paths: + +```ts +import { initBotId } from "botid/client/core"; + +initBotId({ + protect: [ + { path: "/api/auth/sign-up/email", method: "POST" }, + { path: "/api/auth/sign-in/email", method: "POST" }, + { path: "/api/auth/request-password-reset", method: "POST" }, + ], +}); +``` + +BotID uses the browser's full request path. The plugin's `endpoints` option omits the Better Auth base path, as in `/sign-in/email`. Keep both in sync if you change `basePath` or `endpoints`. + +With BotID initialized in the browser, sign in normally without a captcha token header: + +```ts +import { authClient } from "@/lib/auth-client"; + +const { data, error } = await authClient.signIn.email({ + email: "user@example.com", + password: "secure-password", +}); +``` + +BotID does not support [native HTML form submissions](https://vercel.com/docs/botid/form-submissions). + +Local development [allows requests by default](https://vercel.com/docs/botid/local-development-behavior), so verify the full flow on Vercel before relying on it in production. For route-specific detection levels, follow [Vercel's advanced configuration](https://vercel.com/docs/botid/advanced-configuration). + ## How it works - The plugin acts as a middleware: it intercepts all `POST` requests to configured endpoints (see `endpoints` + The plugin acts as a middleware: it intercepts requests to configured endpoints (see `endpoints` in the [Plugin Options](#plugin-options) section). it validates the captcha token on the server, by calling the captcha provider's `/siteverify`. + + + **Vercel BotID** is the exception: it uses the supplied `checkBotId` function instead of a captcha token or `/siteverify` call. + * if the token is missing, gets rejected by the captcha provider, or if the `/siteverify` endpoint is unavailable, the plugin returns an error and interrupts the request. * if the token is accepted by the captcha provider, the middleware returns `undefined`, meaning the request is allowed to proceed. + * for **Vercel BotID**, the request is rejected when `checkBotId` (or your custom `validateRequest`) determines it's a bot, and allowed to proceed otherwise. ## Plugin Options * **`provider` (required)**: your captcha provider. -* **`secretKey` (required)**: your provider's secret key used for the server-side validation. +* **`secretKey` (required for all providers except `vercel-botid`)**: your provider's secret key used for the server-side validation. * `endpoints` (optional): replaces the default array of paths where captcha verification is enforced. If set, only the specified paths will be protected. Paths match exactly unless they include wildcards, such as `/sign-in/*` for one segment or `/sign-in/**` for nested routes. Default is `["/sign-up/email", "/sign-in/email", "/request-password-reset"]`. * `minScore` (optional - only *Google ReCAPTCHA v3*): minimum score threshold. Default is `0.5`. * `siteKey` (optional - only *hCaptcha* and *CaptchaFox*): prevents tokens issued on one sitekey from being redeemed elsewhere. -* `siteVerifyURLOverride` (optional): overrides endpoint URL for the captcha verification request. +* `siteVerifyURLOverride` (optional - token-based providers only): overrides the captcha verification URL. +* `checkBotId` (required for *Vercel BotID*): Vercel's server-side check function, imported from `botid/server` in your application. Wrap it in a function if you need to pass SDK options. +* `validateRequest` (optional - only *Vercel BotID*): custom validation logic. Return `true` to allow the request, `false` to reject it. By default, only results with `isBot: false` are allowed. diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 49c561b14d..13bcc2b5d6 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -504,6 +504,7 @@ "@types/pg": "^8.20.0", "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", + "botid": "^1.5.10", "happy-dom": "^20.8.9", "msw": "^2.14.6", "next": "^16.3.4", diff --git a/packages/better-auth/src/plugins/captcha/captcha.test.ts b/packages/better-auth/src/plugins/captcha/captcha.test.ts index 27748997fc..a53a85ce4c 100644 --- a/packages/better-auth/src/plugins/captcha/captcha.test.ts +++ b/packages/better-auth/src/plugins/captcha/captcha.test.ts @@ -1,4 +1,5 @@ import * as betterFetchModule from "@better-fetch/fetch"; +import { checkBotId as vercelCheckBotId } from "botid/server"; import { describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { emailOTP } from "../email-otp"; @@ -866,6 +867,198 @@ describe("captcha", async () => { }); }); + describe("vercel-botid", () => { + it.each([ + "/sign-up/email", + "/sign-in/email", + "/request-password-reset", + ])("rejects bots on %s without a captcha token or secret key", async (path) => { + const checkBotId = vi.fn(async () => ({ isBot: true })); + const { auth } = await getTestInstance({ + plugins: [captcha({ provider: "vercel-botid", checkBotId })], + }); + const res = await auth.handler( + new Request(`http://localhost:3000/api/auth${path}`, { + method: "POST", + }), + ); + + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + code: "VERIFICATION_FAILED", + }); + expect(checkBotId).toHaveBeenCalledOnce(); + }); + + /** + * @see https://vercel.com/docs/botid/local-development-behavior + */ + it("rejects the SDK's development BAD-BOT verdict", async () => { + const checkBotId = () => + vercelCheckBotId({ developmentOptions: { bypass: "BAD-BOT" } }); + const { auth } = await getTestInstance({ + plugins: [captcha({ provider: "vercel-botid", checkBotId })], + }); + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/sign-in/email", { + method: "POST", + }), + ); + + expect(res.status).toBe(403); + expect(await res.json()).toMatchObject({ + code: "VERIFICATION_FAILED", + }); + }); + + /** + * @see https://vercel.com/docs/botid/local-development-behavior + */ + it("allows the SDK's development HUMAN verdict without a captcha token", async () => { + const checkBotId = vi.fn(() => + vercelCheckBotId({ developmentOptions: { bypass: "HUMAN" } }), + ); + const { client, testUser } = await getTestInstance({ + plugins: [captcha({ provider: "vercel-botid", checkBotId })], + }); + const res = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + + expect(res.error).toBeNull(); + expect(res.data?.user).toBeDefined(); + expect(checkBotId).toHaveBeenCalledOnce(); + }); + + it("allows a verified bot when custom validation opts in", async () => { + const checkBotId = vi.fn(async () => ({ + isBot: true, + isVerifiedBot: true, + verifiedBotName: "trusted-agent", + })); + const { client, testUser } = await getTestInstance({ + plugins: [ + captcha({ + provider: "vercel-botid", + checkBotId, + validateRequest: ({ verification }) => + verification.isVerifiedBot === true && + verification.verifiedBotName === "trusted-agent", + }), + ], + }); + const res = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + + expect(res.error).toBeNull(); + expect(res.data?.user).toBeDefined(); + }); + + it("rejects a human when custom validation rejects the request", async () => { + const checkBotId = vi.fn(async () => ({ isBot: false })); + const { client } = await getTestInstance({ + plugins: [ + captcha({ + provider: "vercel-botid", + checkBotId, + validateRequest: () => false, + }), + ], + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + }); + + expect(res.error?.status).toBe(403); + }); + + it("returns a generic 500 when the check fails", async () => { + const checkBotId = vi.fn(async () => { + throw new Error("private BotID failure"); + }); + const { client } = await getTestInstance({ + plugins: [captcha({ provider: "vercel-botid", checkBotId })], + }); + const res = await client.signIn.email({ + email: "test@test.com", + password: "test123456", + }); + + expect(res.error?.status).toBe(500); + expect(res.error?.code).toBe("UNKNOWN_ERROR"); + expect(res.error?.message).toBe("Something went wrong"); + }); + + it("does not check routes outside the protected endpoints", async () => { + const checkBotId = vi.fn(async () => ({ isBot: true })); + const { auth } = await getTestInstance({ + plugins: [captcha({ provider: "vercel-botid", checkBotId })], + }); + const res = await auth.handler( + new Request("http://localhost:3000/api/auth/ok"), + ); + + expect(res.status).toBe(200); + expect(checkBotId).not.toHaveBeenCalled(); + }); + + it("fails closed when the check never resolves", async () => { + vi.useFakeTimers(); + try { + const checkBotId = () => new Promise<{ isBot: boolean }>(() => {}); + const { vercelBotId } = await import("./verify-handlers/vercel-botid"); + const resultPromise = vercelBotId({ + request: new Request("http://localhost/sign-in/email"), + checkBotId, + }); + const settled = vi.fn(); + void resultPromise.then(settled, settled); + const assertion = expect(resultPromise).rejects.toThrow( + "CAPTCHA service unavailable", + ); + + await vi.advanceTimersByTimeAsync(CAPTCHA_VERIFY_TIMEOUT_MS - 1); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await assertion; + expect(settled).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("fails closed when custom validation never resolves", async () => { + vi.useFakeTimers(); + try { + const { vercelBotId } = await import("./verify-handlers/vercel-botid"); + const resultPromise = vercelBotId({ + request: new Request("http://localhost/sign-in/email"), + checkBotId: async () => ({ isBot: false }), + validateRequest: () => new Promise(() => {}), + }); + const settled = vi.fn(); + void resultPromise.then(settled, settled); + const assertion = expect(resultPromise).rejects.toThrow( + "CAPTCHA service unavailable", + ); + + await vi.advanceTimersByTimeAsync(CAPTCHA_VERIFY_TIMEOUT_MS - 1); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await assertion; + expect(settled).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + }); + describe("action and hostname binding", async () => { it("rejects a Turnstile token whose action does not match expectedAction", async () => { const log = vi.fn(); diff --git a/packages/better-auth/src/plugins/captcha/constants.ts b/packages/better-auth/src/plugins/captcha/constants.ts index 72dfbacebc..6b04807eec 100644 --- a/packages/better-auth/src/plugins/captcha/constants.ts +++ b/packages/better-auth/src/plugins/captcha/constants.ts @@ -19,9 +19,13 @@ export const Providers = { GOOGLE_RECAPTCHA: "google-recaptcha", HCAPTCHA: "hcaptcha", CAPTCHAFOX: "captchafox", + VERCEL_BOTID: "vercel-botid", } as const; -export const siteVerifyMap: Record = { +export const siteVerifyMap: Record< + Exclude, + string +> = { [Providers.CLOUDFLARE_TURNSTILE]: "https://challenges.cloudflare.com/turnstile/v0/siteverify", [Providers.GOOGLE_RECAPTCHA]: diff --git a/packages/better-auth/src/plugins/captcha/index.ts b/packages/better-auth/src/plugins/captcha/index.ts index 28f27ad639..9f584496a3 100644 --- a/packages/better-auth/src/plugins/captcha/index.ts +++ b/packages/better-auth/src/plugins/captcha/index.ts @@ -58,6 +58,14 @@ export const captcha = (options: CaptchaOptions) => return undefined; } + if (options.provider === Providers.VERCEL_BOTID) { + return await verifyHandlers.vercelBotId({ + request, + checkBotId: options.checkBotId, + validateRequest: options.validateRequest, + }); + } + if (!options.secretKey) { throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message); } diff --git a/packages/better-auth/src/plugins/captcha/types.ts b/packages/better-auth/src/plugins/captcha/types.ts index 60c0004dc4..83fc7d0fd5 100644 --- a/packages/better-auth/src/plugins/captcha/types.ts +++ b/packages/better-auth/src/plugins/captcha/types.ts @@ -52,8 +52,55 @@ export interface CaptchaFoxOptions extends BaseCaptchaOptions { siteKey?: string | undefined; } +/** + * BotID verdict fields available to custom request validation. + * + * @see https://vercel.com/docs/botid/verified-bots + */ +export type BotIdVerification = { + isBot: boolean; + isVerifiedBot?: boolean | undefined; + verifiedBotName?: string | undefined; + verifiedBotCategory?: string | undefined; +}; + +export type ValidateRequestContext = { + request: Request; + verification: BotIdVerification; +}; + +/** + * Protect auth requests with Vercel BotID. The browser must also protect the + * same request paths and methods with BotID's client SDK. + * + * @see https://vercel.com/docs/botid/get-started + */ +export interface VercelBotIdOptions { + provider: typeof Providers.VERCEL_BOTID; + /** + * Auth paths to verify, without the Better Auth base path. + * + * @default ["/sign-up/email", "/sign-in/email", "/request-password-reset"] + */ + endpoints?: string[] | undefined; + /** + * Vercel's server-side check for the current request. Pass `checkBotId` from + * `botid/server`, or wrap it to supply SDK options. + */ + checkBotId: () => Promise; + + /** + * Override the default `isBot === false` decision. Return `true` to allow + * the request, including a verified bot you trust. + * + * @see https://vercel.com/docs/botid/verified-bots + */ + validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise; +} + export type CaptchaOptions = | GoogleRecaptchaOptions | CloudflareTurnstileOptions | HCaptchaOptions - | CaptchaFoxOptions; + | CaptchaFoxOptions + | VercelBotIdOptions; diff --git a/packages/better-auth/src/plugins/captcha/utils.ts b/packages/better-auth/src/plugins/captcha/utils.ts index 80e99f4247..ef8151f036 100644 --- a/packages/better-auth/src/plugins/captcha/utils.ts +++ b/packages/better-auth/src/plugins/captcha/utils.ts @@ -13,3 +13,21 @@ export const encodeToURLParams = (obj: Record): string => { return params.toString(); }; + +/** + * Rejects after `timeoutMs` without cancelling the underlying promise. + */ +export const withTimeout = ( + promise: Promise, + timeoutMs: number, +): Promise => { + let timer: ReturnType; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("Operation timed out")), + timeoutMs, + ); + }); + + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +}; diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts index 3f22de64c0..60b290da11 100644 --- a/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/index.ts @@ -2,3 +2,4 @@ export { captchaFox } from "./captchafox"; export { cloudflareTurnstile } from "./cloudflare-turnstile"; export { googleRecaptcha } from "./google-recaptcha"; export { hCaptcha } from "./h-captcha"; +export { vercelBotId } from "./vercel-botid"; diff --git a/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts b/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts new file mode 100644 index 0000000000..be5b5cb0d0 --- /dev/null +++ b/packages/better-auth/src/plugins/captcha/verify-handlers/vercel-botid.ts @@ -0,0 +1,42 @@ +import { middlewareResponse } from "../../../utils/middleware-response"; +import { CAPTCHA_VERIFY_TIMEOUT_MS } from "../constants"; +import { EXTERNAL_ERROR_CODES, INTERNAL_ERROR_CODES } from "../error-codes"; +import type { BotIdVerification, ValidateRequestContext } from "../types"; +import { withTimeout } from "../utils"; + +type Params = { + request: Request; + checkBotId: () => Promise; + validateRequest?: (ctx: ValidateRequestContext) => boolean | Promise; +}; + +export const vercelBotId = async ({ + request, + checkBotId, + validateRequest, +}: Params) => { + let isValid: boolean; + try { + isValid = await withTimeout( + (async () => { + const verification = await checkBotId(); + return validateRequest + ? validateRequest({ request, verification }) + : verification.isBot === false; + })(), + CAPTCHA_VERIFY_TIMEOUT_MS, + ); + } catch (error) { + throw new Error(INTERNAL_ERROR_CODES.SERVICE_UNAVAILABLE.message, { + cause: error, + }); + } + + if (isValid) return undefined; + + return middlewareResponse({ + message: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.message, + code: EXTERNAL_ERROR_CODES.VERIFICATION_FAILED.code, + status: 403, + }); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5e5b330b7..0ab9a48f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1109,6 +1109,9 @@ importers: '@types/react-dom': specifier: catalog:react19 version: 19.2.3(@types/react@19.2.17) + botid: + specifier: ^1.5.10 + version: 1.5.11(next@16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) happy-dom: specifier: '>=20.8.9 <21' version: 20.8.9 @@ -8386,6 +8389,17 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + botid@1.5.11: + resolution: {integrity: sha512-KOO1A3+/vFVJk5aFoG3sNwiogKFPVR+x4aw4gQ1b2e0XoE+i5xp48/EZn+WqR07jRHeDGwHWQUOtV5WVm7xiww==} + peerDependencies: + next: '*' + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + boxen@8.0.1: resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} engines: {node: '>=18'} @@ -24562,6 +24576,11 @@ snapshots: boolbase@1.0.0: {} + botid@1.5.11(next@16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + optionalDependencies: + next: 16.3.4(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + boxen@8.0.1: dependencies: ansi-align: 3.0.1