diff --git a/src/lib/auth-turnstile.test.ts b/src/lib/auth-turnstile.test.ts new file mode 100644 index 0000000..03acdef --- /dev/null +++ b/src/lib/auth-turnstile.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + getHostedTurnstileSecretKey, + hasHostedTurnstileConfig, +} from "@/lib/auth-turnstile"; + +describe("hosted Turnstile auth config", () => { + it("enforces captcha from the hosted server secret even when the runtime site key is absent", () => { + expect( + getHostedTurnstileSecretKey({ + AUTH_MODE: "hosted", + TURNSTILE_SECRET_KEY: " server-secret ", + }), + ).toBe("server-secret"); + }); + + it("does not install captcha outside hosted mode", () => { + expect( + getHostedTurnstileSecretKey({ + AUTH_MODE: "local_noauth", + TURNSTILE_SECRET_KEY: "server-secret", + }), + ).toBeUndefined(); + }); + + it("fails hosted config when a runtime site key is configured without a secret", () => { + expect( + hasHostedTurnstileConfig({ + AUTH_MODE: "hosted", + TURNSTILE_SITE_KEY: "site-key", + }), + ).toBe(false); + }); + + it("allows hosted config with no runtime site key so build/runtime divergence can still enforce from the secret", () => { + expect( + hasHostedTurnstileConfig({ + AUTH_MODE: "hosted", + TURNSTILE_SECRET_KEY: "server-secret", + }), + ).toBe(true); + }); +}); diff --git a/src/lib/auth-turnstile.ts b/src/lib/auth-turnstile.ts new file mode 100644 index 0000000..6fe6cdc --- /dev/null +++ b/src/lib/auth-turnstile.ts @@ -0,0 +1,26 @@ +import { isHostedAuthMode } from "@/lib/auth-mode"; + +type TurnstileAuthEnv = { + AUTH_MODE?: string; + TURNSTILE_SECRET_KEY?: string; + TURNSTILE_SITE_KEY?: string; +}; + +export function getHostedTurnstileSecretKey(env: TurnstileAuthEnv) { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return undefined; + } + + return env.TURNSTILE_SECRET_KEY?.trim() || undefined; +} + +export function hasHostedTurnstileConfig(env: TurnstileAuthEnv) { + const siteKey = env.TURNSTILE_SITE_KEY?.trim(); + const secretKey = env.TURNSTILE_SECRET_KEY?.trim(); + + // No runtime site key means the Worker is allowed to rely on a client build + // value while still enforcing with the server secret when it is present. If a + // runtime site key is configured, require the matching secret so the hosted + // auth route fails closed instead of showing a non-enforced captcha. + return !siteKey || Boolean(secretKey); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5e1ee3a..3e9cde8 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -13,6 +13,10 @@ import { getDatabaseProvider } from "@/db/provider"; import { z } from "zod"; import { isHostedAuthMode } from "@/lib/auth-mode"; import { createBaseAuthConfig } from "@/lib/auth-config"; +import { + getHostedTurnstileSecretKey, + hasHostedTurnstileConfig, +} from "@/lib/auth-turnstile"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { sendHostedPasswordResetEmail, @@ -41,17 +45,12 @@ function createAuth() { const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const baseAuthConfig = createBaseAuthConfig(); - // Turnstile captcha on signup — hosted only, and only when BOTH keys are set. - // Requiring the site key too (not just the secret) keeps the server in - // lockstep with the client widget, which renders only when the site key is - // present: a secret-only deploy would otherwise fail closed and reject every - // signup (client sends no token). Left off entirely when unconfigured so - // local/self-hosted builds are unaffected. Relies on the same - // build-env == runtime-env contract as AUTH_MODE. - const turnstileSecretKey = - isHostedAuthMode(env.AUTH_MODE) && env.TURNSTILE_SITE_KEY?.trim() - ? env.TURNSTILE_SECRET_KEY?.trim() - : undefined; + // Turnstile captcha on signup — hosted only. Enforcement is driven by the + // server-side secret alone so a client build/runtime site-key mismatch cannot + // silently omit the Better Auth captcha plugin. Hosted deployments that expose + // the client widget without the matching server secret fail configuration + // checks instead of presenting a bypassable captcha. + const turnstileSecretKey = getHostedTurnstileSecretKey(env); const database = getDatabaseProvider() === "postgres" @@ -274,8 +273,9 @@ export function hasHostedAuthConfig() { getHostedSecret(); getGoogleSocialProviderConfig(); return ( - Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" || - hasHostedAuthEmailConfig() + hasHostedTurnstileConfig(env) && + (Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true" || + hasHostedAuthEmailConfig()) ); } catch { return false;