diff --git a/README.md b/README.md index 1014ec89..8ce397c5 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,27 @@ const app = issuer({ }) ``` +#### Turnstile (optional) + +You can optionally require Cloudflare Turnstile for the built-in `password` and `code` providers. + +```ts +import { PasswordProvider } from "@openauthjs/openauth/provider/password" +import { PasswordUI } from "@openauthjs/openauth/ui/password" + +password: PasswordProvider( + PasswordUI({ + sendCode: async (email, code) => { + console.log(email, code) + }, + turnstile: { + siteKey: process.env.TURNSTILE_SITE_KEY!, + secretKey: process.env.TURNSTILE_SECRET_KEY!, + }, + }), +), +``` + Next up is the `subjects` field. Subjects are what the access token generated at the end of the auth flow will map to. Under the hood, the access token is a JWT that contains this data. You will likely just have a single subject to start but you can define additional ones for different types of users. ```ts diff --git a/package.json b/package.json index 5c5606eb..9497db80 100644 --- a/package.json +++ b/package.json @@ -20,4 +20,4 @@ "typescript": "5.6.3" }, "private": true -} +} \ No newline at end of file diff --git a/packages/openauth/src/provider/code.ts b/packages/openauth/src/provider/code.ts index e8f7b709..8196be9a 100644 --- a/packages/openauth/src/provider/code.ts +++ b/packages/openauth/src/provider/code.ts @@ -55,6 +55,11 @@ import { Context } from "hono" import { Provider } from "./provider.js" import { generateUnbiasedDigits, timingSafeCompare } from "../random.js" +import { + getTurnstileToken, + TurnstileOptions, + verifyTurnstileToken, +} from "../turnstile.js" export interface CodeProviderConfig< Claims extends Record = Record, @@ -96,6 +101,13 @@ export interface CodeProviderConfig< * ``` */ sendCode: (claims: Claims, code: string) => Promise + /** + * Optionally enable Cloudflare Turnstile for the code request/resend actions. + * + * When enabled, the provider verifies the Turnstile token server-side and rejects requests + * with missing or invalid tokens. + */ + turnstile?: TurnstileOptions } /** @@ -129,6 +141,9 @@ export type CodeProviderError = | { type: "invalid_code" } + | { + type: "turnstile" + } | { type: "invalid_claim" key: string @@ -146,6 +161,20 @@ export function CodeProvider< return { type: "code", init(routes, ctx) { + async function verifyTurnstile(req: Request, fd: FormData) { + if (!config.turnstile) return true + const token = getTurnstileToken(fd, config.turnstile.fieldName) + if (!token) return false + const result = await verifyTurnstileToken({ + secretKey: config.turnstile.secretKey, + token, + req, + action: config.turnstile.action, + hostnames: config.turnstile.hostnames, + }) + return result.success + } + async function transition( c: Context, next: CodeProviderState, @@ -173,6 +202,8 @@ export function CodeProvider< const action = fd.get("action")?.toString() if (action === "request" || action === "resend") { + if (!(await verifyTurnstile(c.req.raw, fd))) + return transition(c, { type: "start" }, fd, { type: "turnstile" }) const claims = Object.fromEntries(fd) as Claims delete claims.action const err = await config.sendCode(claims, code) diff --git a/packages/openauth/src/provider/password.ts b/packages/openauth/src/provider/password.ts index 850af8a3..d9bb5432 100644 --- a/packages/openauth/src/provider/password.ts +++ b/packages/openauth/src/provider/password.ts @@ -42,6 +42,11 @@ import { Storage } from "../storage/storage.js" import { Provider } from "./provider.js" import { generateUnbiasedDigits, timingSafeCompare } from "../random.js" import { v1 } from "@standard-schema/spec" +import { + getTurnstileToken, + TurnstileOptions, + verifyTurnstileToken, +} from "../turnstile.js" /** * @internal @@ -141,6 +146,13 @@ export interface PasswordConfig { validatePassword?: | v1.StandardSchema | ((password: string) => Promise | string | undefined) + /** + * Optionally enable Cloudflare Turnstile for sensitive actions. + * + * When enabled, the provider verifies the Turnstile token server-side and rejects requests + * with missing or invalid tokens. + */ + turnstile?: TurnstileOptions } /** @@ -177,6 +189,9 @@ export type PasswordRegisterError = | { type: "invalid_code" } + | { + type: "turnstile" + } | { type: "email_taken" } @@ -234,6 +249,9 @@ export type PasswordChangeError = | { type: "invalid_email" } + | { + type: "turnstile" + } | { type: "invalid_code" } @@ -263,6 +281,9 @@ export type PasswordLoginError = | { type: "invalid_email" } + | { + type: "turnstile" + } export function PasswordProvider( config: PasswordConfig, @@ -274,6 +295,20 @@ export function PasswordProvider( return { type: "password", init(routes, ctx) { + async function verifyTurnstile(req: Request, fd: FormData) { + if (!config.turnstile) return true + const token = getTurnstileToken(fd, config.turnstile.fieldName) + if (!token) return false + const result = await verifyTurnstileToken({ + secretKey: config.turnstile.secretKey, + token, + req, + action: config.turnstile.action, + hostnames: config.turnstile.hostnames, + }) + return result.success + } + routes.get("/authorize", async (c) => ctx.forward(c, await config.login(c.req.raw)), ) @@ -283,6 +318,8 @@ export function PasswordProvider( async function error(err: PasswordLoginError) { return ctx.forward(c, await config.login(c.req.raw, fd, err)) } + if (!(await verifyTurnstile(c.req.raw, fd))) + return error({ type: "turnstile" }) const email = fd.get("email")?.toString()?.toLowerCase() if (!email) return error({ type: "invalid_email" }) const hash = await Storage.get(ctx.storage, [ @@ -338,6 +375,8 @@ export function PasswordProvider( } if (action === "register" && provider.type === "start") { + if (!(await verifyTurnstile(c.req.raw, fd))) + return transition(provider, { type: "turnstile" }) const password = fd.get("password")?.toString() const repeat = fd.get("repeat")?.toString() if (!email) return transition(provider, { type: "invalid_email" }) @@ -387,6 +426,8 @@ export function PasswordProvider( } if (action === "register" && provider.type === "code") { + if (!(await verifyTurnstile(c.req.raw, fd))) + return transition(provider, { type: "turnstile" }) const code = generate() await config.sendCode(provider.email, code) return transition({ @@ -447,6 +488,8 @@ export function PasswordProvider( } if (action === "code") { + if (!(await verifyTurnstile(c.req.raw, fd))) + return transition(provider, { type: "turnstile" }) const email = fd.get("email")?.toString()?.toLowerCase() if (!email) return transition( diff --git a/packages/openauth/src/turnstile.ts b/packages/openauth/src/turnstile.ts new file mode 100644 index 00000000..e9342871 --- /dev/null +++ b/packages/openauth/src/turnstile.ts @@ -0,0 +1,152 @@ +export const DEFAULT_TURNSTILE_FIELD_NAME = "cf-turnstile-response" + +export interface TurnstileSiteVerifyResponse { + success: boolean + challenge_ts?: string + hostname?: string + action?: string + cdata?: string + "error-codes"?: string[] +} + +export interface TurnstileOptions { + /** + * Cloudflare Turnstile secret key. + */ + secretKey: string + /** + * Cloudflare Turnstile site key (public key). Used by the built-in UI. + */ + siteKey?: string + /** + * The form field name containing the Turnstile token. + * @default "cf-turnstile-response" + */ + fieldName?: string + /** + * When set, require the `action` returned by Turnstile to match. + */ + action?: string + /** + * When set, require the `hostname` returned by Turnstile to match one of these. + */ + hostnames?: string[] + /** + * Optional widget options used by the built-in UI. + */ + widget?: { + theme?: "auto" | "light" | "dark" + size?: "normal" | "compact" | "invisible" + appearance?: "always" | "execute" | "interaction-only" + retry?: "auto" | "never" + refreshExpired?: "auto" | "manual" | "never" + } +} + +export function getTurnstileToken( + form: FormData, + fieldName: string = DEFAULT_TURNSTILE_FIELD_NAME, +) { + const token = form.get(fieldName)?.toString() + return token ? token : undefined +} + +function getClientIP(req: Request) { + const from = (value: string | null) => value?.split(",")[0]?.trim() + return ( + from(req.headers.get("cf-connecting-ip")) || + from(req.headers.get("x-forwarded-for")) || + from(req.headers.get("x-real-ip")) || + undefined + ) +} + +function getRequestHostname(req: Request) { + const forwardedHost = req.headers.get("x-forwarded-host") + if (forwardedHost) { + const raw = forwardedHost.split(",")[0]?.trim() + if (!raw) return undefined + try { + return new URL(`https://${raw}`).hostname + } catch { + return raw.split(":")[0] + } + } + try { + return new URL(req.url).hostname + } catch { + return undefined + } +} + +export type TurnstileVerifyResult = { + success: boolean + response?: TurnstileSiteVerifyResponse + errorCodes?: string[] +} + +/** + * Verifies a Cloudflare Turnstile token server-side. Fails closed. + */ +export async function verifyTurnstileToken(opts: { + secretKey: string + token: string + req?: Request + action?: string + hostnames?: string[] +}): Promise { + const body = new URLSearchParams({ + secret: opts.secretKey, + response: opts.token, + }) + + const clientIP = opts.req ? getClientIP(opts.req) : undefined + if (clientIP) body.set("remoteip", clientIP) + + let json: TurnstileSiteVerifyResponse + try { + const res = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }, + ) + json = (await res.json()) as TurnstileSiteVerifyResponse + } catch (err) { + return { + success: false, + errorCodes: ["siteverify_failed"], + } + } + + if (!json?.success) { + return { + success: false, + response: json, + errorCodes: json?.["error-codes"] ?? ["invalid_token"], + } + } + + if (opts.action && json.action !== opts.action) { + return { + success: false, + response: json, + errorCodes: ["action_mismatch"], + } + } + + if (opts.hostnames?.length) { + const hostname = json.hostname + if (!hostname || !opts.hostnames.includes(hostname)) { + return { + success: false, + response: json, + errorCodes: ["hostname_mismatch"], + } + } + } + + return { success: true, response: json } +} diff --git a/packages/openauth/src/ui/code.tsx b/packages/openauth/src/ui/code.tsx index 5ed77392..706a4299 100644 --- a/packages/openauth/src/ui/code.tsx +++ b/packages/openauth/src/ui/code.tsx @@ -28,6 +28,8 @@ import { CodeProviderOptions } from "../provider/code.js" import { UnknownStateError } from "../error.js" import { Layout } from "./base.js" import { FormAlert } from "./form.js" +import { TurnstileOptions } from "../turnstile.js" +import { TurnstileScript, TurnstileWidget } from "./turnstile.js" const DEFAULT_COPY = { /** @@ -70,6 +72,10 @@ const DEFAULT_COPY = { * Copy for the resend button. */ code_resend: "Resend", + /** + * Error message when Turnstile is missing or invalid. + */ + turnstile_invalid: "Please complete the challenge.", } export type CodeUICopy = typeof DEFAULT_COPY @@ -101,6 +107,10 @@ export interface CodeUIOptions { * @default "email" */ mode?: "email" | "phone" + /** + * Optionally enable Cloudflare Turnstile for the code request/resend actions. + */ + turnstile?: TurnstileOptions } /** @@ -114,18 +124,24 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions { } const mode = props.mode ?? "email" + const turnstile = props.turnstile?.siteKey ? props.turnstile : undefined return { sendCode: props.sendCode, length: 6, + turnstile: props.turnstile, request: async (_req, state, _form, error): Promise => { if (state.type === "start") { const jsx = ( + {turnstile && }
{error?.type === "invalid_claim" && ( )} + {error?.type === "turnstile" && ( + + )} + {turnstile && ( + + )}

{copy.code_info}

@@ -151,6 +174,7 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions { if (state.type === "code") { const jsx = ( + {turnstile && }
{error?.type === "invalid_code" && ( @@ -190,6 +214,13 @@ export function CodeUI(props: CodeUIOptions): CodeProviderOptions { /> ))} + {turnstile && ( + + )}
{copy.code_didnt_get}{" "} diff --git a/packages/openauth/src/ui/password.tsx b/packages/openauth/src/ui/password.tsx index 360f28da..15996638 100644 --- a/packages/openauth/src/ui/password.tsx +++ b/packages/openauth/src/ui/password.tsx @@ -33,6 +33,7 @@ import { import { Layout } from "./base.js" import "./form.js" import { FormAlert } from "./form.js" +import { TurnstileScript, TurnstileWidget } from "./turnstile.js" const DEFAULT_COPY = { /** @@ -59,6 +60,10 @@ const DEFAULT_COPY = { * Error message when the user enters a password that fails validation. */ error_validation_error: "Password does not meet requirements.", + /** + * Error message when Turnstile is missing or invalid. + */ + error_turnstile: "Please complete the challenge.", /** * Title of the register page. */ @@ -141,7 +146,7 @@ type PasswordUICopy = typeof DEFAULT_COPY * Configure the password UI. */ export interface PasswordUIOptions - extends Pick { + extends Pick { /** * Custom copy for the UI. */ @@ -157,12 +162,15 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { ...DEFAULT_COPY, ...input.copy, } + const turnstile = input.turnstile?.siteKey ? input.turnstile : undefined return { validatePassword: input.validatePassword, sendCode: input.sendCode, + turnstile: input.turnstile, login: async (_req, form, error): Promise => { const jsx = ( + {turnstile && } + {turnstile && ( + + )}
@@ -216,6 +231,7 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { ].includes(error?.type || "") const jsx = ( + {turnstile && } + {turnstile && ( + + )}
@@ -304,6 +327,7 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { ].includes(error?.type || "") const jsx = ( + {turnstile && } + {turnstile && ( + + )} )} {state.type === "code" && ( @@ -377,6 +408,13 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig { + {turnstile && ( + + )} {state.type === "code" && (
diff --git a/packages/openauth/src/ui/turnstile.tsx b/packages/openauth/src/ui/turnstile.tsx new file mode 100644 index 00000000..a88f15bb --- /dev/null +++ b/packages/openauth/src/ui/turnstile.tsx @@ -0,0 +1,33 @@ +/** @jsxImportSource hono/jsx */ + +import { TurnstileOptions } from "../turnstile.js" + +export function TurnstileScript() { + return ( +