Files
Leon SerfatyandClaude Opus 5 8f90347659 feat(auth): finish Turnstile coverage across every auth entry point
Turnstile protected sign-in, sign-up and password-reset, but three gaps
remained:

- updatePassword had no verification and its page had no widget, so the
  final step of the reset flow was unprotected. The reset token is now
  carried through the failure redirect so a failed challenge doesn't
  strand the user on a form whose emailed link can't be replayed.

- /api/auth/[...all] exposed better-auth's handler directly, accepting
  unlimited credential guesses and email sends with no bot protection --
  a full bypass of the page-level checks. Credential-bearing POSTs now
  require a verified token. The gate lives in the route handler, so the
  server actions (which call auth.api.* in-process) are unaffected. GET
  is untouched for OAuth callbacks and verify-email links, and
  /sign-in/social stays open since it only redirects to the provider.

- Turnstile tokens expire after ~5 minutes and the widget never reset,
  so a form left open submitted a stale token and failed with "complete
  the verification challenge" despite the challenge visibly passing.

Verified with Cloudflare's test keys: all four auth forms block an
invalid token, pass a valid one through to real auth logic, and the API
gate returns 403 without a token and 401 with one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:26:16 -04:00

162 lines
5.5 KiB
TypeScript

"use server"
import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { APIError } from "better-auth/api"
import { auth, isGoogleConfigured } from "@/lib/auth"
import { verifyTurnstile } from "@/lib/turnstile"
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
const CAPTCHA_ERROR = "Please complete the verification challenge and try again."
/** Post-auth destination — only same-site relative paths (blocks open redirects). */
function safeNext(formData: FormData): string {
const next = formData.get("next")
if (typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\")) {
return next
}
return "/dashboard"
}
export async function signUp(formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
const fullName = formData.get("full_name") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/signup?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.signUpEmail({
// callbackURL is where the verification link lands the user after confirming.
body: { email, password, name: fullName, callbackURL: "/dashboard" },
headers: h,
})
} catch (e) {
const raw = e instanceof APIError ? e.message : "Sign up failed"
// Don't reveal that an email is already registered (user enumeration) — the
// "already exists" path must not be distinguishable from other failures.
const msg = /exist|registered|already|taken/i.test(raw)
? "We couldn't complete your sign-up. Please try a different email or sign in."
: raw
redirect(`/signup?error=${encodeURIComponent(msg)}`)
}
// When email verification is required, the account isn't usable until confirmed —
// send the user to the "check your email" screen instead of the dashboard.
if (process.env.REQUIRE_EMAIL_VERIFICATION === "true") {
redirect("/signup?success=check-email")
}
redirect(safeNext(formData))
}
export async function signIn(formData: FormData) {
const email = formData.get("email") as string
const password = formData.get("password") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/login?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.signInEmail({
body: { email, password },
headers: h,
})
} catch (e) {
const msg = e instanceof APIError ? e.message : "Invalid email or password"
redirect(`/login?error=${encodeURIComponent(msg)}`)
}
redirect(safeNext(formData))
}
export async function signInWithGoogle() {
// Defense in depth: the auth pages hide the Google button when it isn't
// configured, but guard the action too in case it's POSTed directly.
if (!isGoogleConfigured()) {
redirect(`/login?error=${encodeURIComponent("Google sign-in isn't available right now.")}`)
}
let url: string | undefined
try {
const res = await auth.api.signInSocial({
body: { provider: "google", callbackURL: "/dashboard" },
headers: await headers(),
})
url = res?.url ?? undefined
} catch (e) {
const msg = e instanceof APIError ? e.message : "Google sign-in failed"
redirect(`/login?error=${encodeURIComponent(msg)}`)
}
if (url) redirect(url)
redirect("/login?error=google_failed")
}
export async function resetPassword(formData: FormData) {
const email = formData.get("email") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
const h = await headers()
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(`/forgot-password?error=${encodeURIComponent(CAPTCHA_ERROR)}`)
}
try {
await auth.api.requestPasswordReset({
body: { email, redirectTo: `${APP_URL}/update-password` },
headers: h,
})
} catch {
// Always report success so we don't reveal whether an account exists.
}
redirect("/forgot-password?success=email-sent")
}
export async function signOut() {
try {
await auth.api.signOut({ headers: await headers() })
} catch {
// ignore
}
redirect("/login")
}
export async function updatePassword(formData: FormData) {
const password = formData.get("password") as string
const token = formData.get("token") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
if (!token) {
redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`)
}
const h = await headers()
// Same bot protection as the other credential forms. The reset token is
// carried through so a failed challenge doesn't strand the user on a form
// whose link can't be replayed.
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(
`/update-password?error=${encodeURIComponent(CAPTCHA_ERROR)}&token=${encodeURIComponent(token)}`
)
}
try {
await auth.api.resetPassword({
body: { newPassword: password, token },
headers: h,
})
} catch (e) {
const msg = e instanceof APIError ? e.message : "Could not update password"
redirect(`/update-password?error=${encodeURIComponent(msg)}&token=${encodeURIComponent(token)}`)
}
redirect("/login?success=password-updated")
}