From 02277cf4021f5180bb7d574f28943401c4d1d474 Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 14:27:08 +0530 Subject: [PATCH] feat(oauth-server-kit): optional Cloudflare Turnstile on the hosted sign-in form createOAuthPagesRouter takes captcha: { provider: "cloudflare-turnstile", siteKey }. The email form renders the Turnstile widget and sends its token in the x-captcha-response header on the send-code request, which Better Auth's captcha plugin verifies. The token is reset after each attempt because Turnstile tokens are single-use. --- .changeset/login-page-turnstile.md | 9 +++ .../oauth-server-kit/docs/architecture.md | 12 +++- packages/oauth-server-kit/docs/integration.md | 25 +++++++ .../oauth-server-kit/src/express-entry.ts | 1 + .../oauth-server-kit/src/oauth-pages.test.ts | 67 +++++++++++++++++-- packages/oauth-server-kit/src/oauth-pages.ts | 28 ++++++-- 6 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 .changeset/login-page-turnstile.md diff --git a/.changeset/login-page-turnstile.md b/.changeset/login-page-turnstile.md new file mode 100644 index 0000000..6d894fc --- /dev/null +++ b/.changeset/login-page-turnstile.md @@ -0,0 +1,9 @@ +--- +"@codelitdev/oauth-server-kit": minor +--- + +`createOAuthPagesRouter` takes an optional `captcha: { provider: "cloudflare-turnstile", siteKey }`. The hosted email sign-in form then renders a Cloudflare Turnstile widget and sends its token in the `x-captcha-response` header when it asks for a code. Without `captcha` the pages are unchanged. + +### Product changes + +To turn it on, pass `captcha` to `createOAuthPagesRouter` and add Better Auth's `captcha` plugin with `provider: "cloudflare-turnstile"`, the secret key and `endpoints: ["/email-otp/send-verification-otp"]`. Configure both or neither. diff --git a/packages/oauth-server-kit/docs/architecture.md b/packages/oauth-server-kit/docs/architecture.md index a165451..1013132 100644 --- a/packages/oauth-server-kit/docs/architecture.md +++ b/packages/oauth-server-kit/docs/architecture.md @@ -506,8 +506,14 @@ export interface CreateOAuthPagesRouterOptions { logoUrl?: string; faviconUrl?: string; primaryColor?: string; + captcha?: HostedLoginCaptcha; } +export type HostedLoginCaptcha = { + provider: "cloudflare-turnstile"; + siteKey: string; +}; + export function createOAuthPagesRouter( options: CreateOAuthPagesRouterOptions, ): Express.Router; @@ -532,6 +538,10 @@ Hosted-page rules: - A social method calls Better Auth social sign-in using its configured `providerId`. The router does not store or receive provider credentials. - The router renders only the supplied methods; Google is not special-cased. +- With `captcha`, the email form renders a Turnstile widget and sends its + token in the `x-captcha-response` header on the send-code request. The + router does not verify the token; the product verifies it with Better Auth's + `captcha` plugin. - Unsupported or invalid configuration throws synchronously at router creation. @@ -654,7 +664,7 @@ The exact exports are: | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | package root | `AuthenticatedIdentity`, `AuthenticationResult`, `OAuthResourceClient`, `VerifyOAuthAccessTokenOptions`, `verifyOAuthAccessToken` | | `/better-auth` | `BetterAuthSession`, `BetterAuthSessionApi`, `ResolveBetterAuthSessionOptions`, `resolveBetterAuthSession`, `CreateOAuthProviderOptionsInput`, `createOAuthProviderOptions` | -| `/express` | `HostedLoginMethod`, `CreateOAuthPagesRouterOptions`, `createOAuthPagesRouter`, `CreateOAuthBearerMiddlewareOptions`, `createOAuthBearerMiddleware`, Express `Request.auth` augmentation | +| `/express` | `HostedLoginMethod`, `HostedLoginCaptcha`, `CreateOAuthPagesRouterOptions`, `createOAuthPagesRouter`, `CreateOAuthBearerMiddlewareOptions`, `createOAuthBearerMiddleware`, Express `Request.auth` augmentation | | `/mcp` | `BetterAuthMetadataApi`, `CreateMcpOAuthDiscoveryRoutesOptions`, `createMcpOAuthDiscoveryRoutes` | There is no default export. The root does not import Express or Drizzle. diff --git a/packages/oauth-server-kit/docs/integration.md b/packages/oauth-server-kit/docs/integration.md index d793569..cbc1fcc 100644 --- a/packages/oauth-server-kit/docs/integration.md +++ b/packages/oauth-server-kit/docs/integration.md @@ -144,6 +144,11 @@ app.use( label: "Continue with Google", }, ], + // Optional bot check on the email form; see "Turnstile" below. + captcha: { + provider: "cloudflare-turnstile", + siteKey: process.env.TURNSTILE_SITE_KEY!, + }, // Use only during a host-only to shared-domain cookie migration. legacyHostOnlySessionCookieNames: [ "__Secure-better-auth.session_token", @@ -154,6 +159,26 @@ app.use( ); ``` +### Turnstile + +With `captcha` set, the email form renders a Cloudflare Turnstile widget and +sends its token in the `x-captcha-response` header when it asks for a sign-in +code. The page only collects the token; the product must verify it with Better +Auth's `captcha` plugin on the same endpoint, or the check does nothing: + +```ts +import { captcha } from "better-auth/plugins"; + +captcha({ + provider: "cloudflare-turnstile", + secretKey: process.env.TURNSTILE_SECRET_KEY!, + endpoints: ["/email-otp/send-verification-otp"], +}); +``` + +Set both or neither: a server that requires a token while the page renders no +widget refuses every email sign-in. + For a BFF or server-rendered page, validate the session and then perform product authorization: diff --git a/packages/oauth-server-kit/src/express-entry.ts b/packages/oauth-server-kit/src/express-entry.ts index f74589e..3ce9ef1 100644 --- a/packages/oauth-server-kit/src/express-entry.ts +++ b/packages/oauth-server-kit/src/express-entry.ts @@ -5,5 +5,6 @@ export { export { type CreateOAuthPagesRouterOptions, createOAuthPagesRouter, + type HostedLoginCaptcha, type HostedLoginMethod, } from "./oauth-pages"; diff --git a/packages/oauth-server-kit/src/oauth-pages.test.ts b/packages/oauth-server-kit/src/oauth-pages.test.ts index 9e49ac0..2b96870 100644 --- a/packages/oauth-server-kit/src/oauth-pages.test.ts +++ b/packages/oauth-server-kit/src/oauth-pages.test.ts @@ -29,7 +29,14 @@ function pagesApp(overrides: Partial = {}) { /** Runs the login page's inline script against stub DOM and fetch, submits * the email form, and returns the error banner text. */ -async function submitEmail(html: string, response: Response): Promise { +async function submitEmail( + html: string, + response: Response, + { + captchaToken, + fetchCalls = [], + }: { captchaToken?: string; fetchCalls?: RequestInit[] } = {}, +): Promise { const script = html.match(/` + : ""; const email = emailMethod - ? `
` + ? `
${captcha}
` : ""; const social = socialMethods.map(socialButton).join(""); const divider = email && social ? '
or
' : ""; @@ -238,12 +256,12 @@ function loginScript(options: NormalizedOptions, oauth: boolean, redirect?: stri const authBasePath = scriptLiteral(options.authBasePath); const redirectTarget = scriptLiteral(redirect ?? ""); return ``; }