1mod path;
4
5use super::json_body::is_truthy;
6use std::{sync::Arc, time::Duration};
7
8use async_trait::async_trait;
9use better_auth_core::{
10 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
11 AuthSchema,
12};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16const TIMEOUT: Duration = Duration::from_secs(10);
17const DEFAULT_ENDPOINTS: &[&str] = &[
18 "/sign-up/email",
19 "/sign-in/email",
20 "/request-password-reset",
21];
22
23#[derive(Clone, Debug, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct BotIdVerification {
27 pub is_bot: bool,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub is_verified_bot: Option<bool>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub verified_bot_name: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub verified_bot_category: Option<String>,
34}
35
36#[async_trait]
38pub trait BotIdChecker: Send + Sync {
39 async fn check(&self) -> AuthResult<BotIdVerification>;
40}
41
42#[async_trait]
44pub trait BotIdRequestValidator: Send + Sync {
45 async fn validate(
46 &self,
47 request: &AuthRequest,
48 verification: &BotIdVerification,
49 ) -> AuthResult<bool>;
50}
51
52#[derive(Clone)]
54pub enum CaptchaProvider {
55 CloudflareTurnstile {
56 secret_key: String,
57 expected_action: Option<String>,
58 allowed_hostnames: Vec<String>,
59 },
60 GoogleRecaptcha {
61 secret_key: String,
62 min_score: f64,
63 expected_action: Option<String>,
64 allowed_hostnames: Vec<String>,
65 },
66 HCaptcha {
67 secret_key: String,
68 site_key: Option<String>,
69 },
70 CaptchaFox {
71 secret_key: String,
72 site_key: Option<String>,
73 },
74 VercelBotId {
75 check_bot_id: Arc<dyn BotIdChecker>,
76 validate_request: Option<Arc<dyn BotIdRequestValidator>>,
77 },
78}
79
80impl CaptchaProvider {
81 pub fn cloudflare_turnstile(secret_key: impl Into<String>) -> Self {
82 Self::CloudflareTurnstile {
83 secret_key: secret_key.into(),
84 expected_action: None,
85 allowed_hostnames: Vec::new(),
86 }
87 }
88 pub fn google_recaptcha(secret_key: impl Into<String>) -> Self {
89 Self::GoogleRecaptcha {
90 secret_key: secret_key.into(),
91 min_score: 0.5,
92 expected_action: None,
93 allowed_hostnames: Vec::new(),
94 }
95 }
96 pub fn hcaptcha(secret_key: impl Into<String>) -> Self {
97 Self::HCaptcha {
98 secret_key: secret_key.into(),
99 site_key: None,
100 }
101 }
102 pub fn captchafox(secret_key: impl Into<String>) -> Self {
103 Self::CaptchaFox {
104 secret_key: secret_key.into(),
105 site_key: None,
106 }
107 }
108 pub fn vercel_bot_id(check_bot_id: Arc<dyn BotIdChecker>) -> Self {
109 Self::VercelBotId {
110 check_bot_id,
111 validate_request: None,
112 }
113 }
114
115 fn token_settings(&self) -> Option<(&str, &str, Option<&str>)> {
116 match self {
117 Self::CloudflareTurnstile { secret_key, .. } => Some((
118 secret_key,
119 "https://challenges.cloudflare.com/turnstile/v0/siteverify",
120 None,
121 )),
122 Self::GoogleRecaptcha { secret_key, .. } => Some((
123 secret_key,
124 "https://www.google.com/recaptcha/api/siteverify",
125 None,
126 )),
127 Self::HCaptcha {
128 secret_key,
129 site_key,
130 } => Some((
131 secret_key,
132 "https://api.hcaptcha.com/siteverify",
133 site_key.as_deref(),
134 )),
135 Self::CaptchaFox {
136 secret_key,
137 site_key,
138 } => Some((
139 secret_key,
140 "https://api.captchafox.com/siteverify",
141 site_key.as_deref(),
142 )),
143 Self::VercelBotId { .. } => None,
144 }
145 }
146}
147
148#[derive(Clone)]
150pub struct CaptchaPlugin {
151 provider: CaptchaProvider,
152 endpoints: Vec<String>,
153 site_verify_url_override: Option<String>,
154 client: reqwest::Client,
155}
156
157impl CaptchaPlugin {
158 pub fn new(provider: CaptchaProvider) -> Self {
159 Self {
160 provider,
161 endpoints: Vec::new(),
162 site_verify_url_override: None,
163 client: reqwest::Client::new(),
164 }
165 }
166
167 pub fn endpoints(mut self, endpoints: Vec<String>) -> Self {
169 self.endpoints = endpoints;
170 self
171 }
172
173 pub fn site_verify_url_override(mut self, url: impl Into<String>) -> Self {
175 self.site_verify_url_override = Some(url.into());
176 self
177 }
178
179 async fn verify(
180 &self,
181 request: &AuthRequest,
182 ip: Option<String>,
183 ) -> AuthResult<Option<AuthResponse>> {
184 if let CaptchaProvider::VercelBotId {
185 check_bot_id,
186 validate_request,
187 } = &self.provider
188 {
189 let checker = check_bot_id.clone();
190 let validator = validate_request.clone();
191 let request = request.clone();
192 let hook_context = better_auth_core::hooks::current_request_hook_context();
193 let task = tokio::spawn(async move {
195 let verify = async {
196 let verdict = checker.check().await?;
197 match validator {
198 Some(validator) => validator.validate(&request, &verdict).await,
199 None => Ok(!verdict.is_bot),
200 }
201 };
202 match hook_context {
203 Some(context) => {
204 better_auth_core::hooks::with_request_hook_context_value(context, verify)
205 .await
206 }
207 None => verify.await,
208 }
209 });
210 let valid = tokio::time::timeout(TIMEOUT, task)
211 .await
212 .map_err(|_| AuthError::internal("CAPTCHA verification timed out"))?
213 .map_err(|_| AuthError::internal("CAPTCHA callback failed"))??;
214 return if valid {
215 Ok(None)
216 } else {
217 failure(403, "VERIFICATION_FAILED", "Captcha verification failed").map(Some)
218 };
219 }
220 let (secret, default_url, site_key) = self
221 .provider
222 .token_settings()
223 .ok_or_else(|| AuthError::internal("Missing CAPTCHA provider"))?;
224 if secret.is_empty() {
225 return Err(AuthError::internal("Missing CAPTCHA secret key"));
226 }
227 let response = request
228 .headers
229 .iter()
230 .find(|(key, _)| key.eq_ignore_ascii_case("x-captcha-response"))
231 .map(|(_, value)| value.as_str())
232 .filter(|value| !value.is_empty());
233 let Some(response) = response else {
234 return failure(400, "MISSING_RESPONSE", "Missing CAPTCHA response").map(Some);
235 };
236 let url = self
237 .site_verify_url_override
238 .as_deref()
239 .filter(|url| !url.is_empty())
240 .unwrap_or(default_url);
241 let mut body = vec![("secret", secret), ("response", response)];
242 if let Some(site_key) = site_key.filter(|value| !value.is_empty()) {
243 body.push(("sitekey", site_key));
244 }
245 if let Some(ip) = ip.as_deref() {
246 body.push((
247 if matches!(self.provider, CaptchaProvider::CaptchaFox { .. }) {
248 "remoteIp"
249 } else {
250 "remoteip"
251 },
252 ip,
253 ));
254 }
255 let post = self.client.post(url);
256 let post = if matches!(self.provider, CaptchaProvider::CloudflareTurnstile { .. }) {
257 post.json(
258 &body
259 .into_iter()
260 .collect::<std::collections::BTreeMap<_, _>>(),
261 )
262 } else {
263 post.form(&body)
264 };
265 let response = tokio::time::timeout(TIMEOUT, post.send())
267 .await
268 .map_err(|_| AuthError::internal("CAPTCHA verification timed out"))?
269 .map_err(|_| AuthError::internal("CAPTCHA service unavailable"))?;
270 if !response.status().is_success() {
271 return Err(AuthError::internal("CAPTCHA service unavailable"));
272 }
273 let content_type = response
274 .headers()
275 .get("content-type")
276 .and_then(|header| header.to_str().ok())
277 .unwrap_or("");
278 let media_type = content_type.split(';').next().unwrap_or("");
279 let is_text = content_type.is_empty()
280 || media_type.starts_with("text/")
281 || matches!(
282 media_type,
283 "image/svg" | "application/xml" | "application/xhtml" | "application/html"
284 )
285 || media_type
286 .to_ascii_lowercase()
287 .strip_prefix("application/")
288 .is_some_and(|subtype| subtype == "json" || subtype.ends_with("+json"));
289 let text = response
290 .text()
291 .await
292 .map_err(|_| AuthError::internal("CAPTCHA response read failed"))?;
293 if !is_text {
294 return failure(403, "VERIFICATION_FAILED", "Captcha verification failed").map(Some);
295 }
296 let data: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text));
298 if !is_truthy(&data) {
299 return Err(AuthError::internal("CAPTCHA service unavailable"));
300 }
301 let mut valid = data.get("success").is_some_and(is_truthy);
302 if let CaptchaProvider::GoogleRecaptcha { min_score, .. } = &self.provider
303 && data
304 .get("score")
305 .and_then(Value::as_f64)
306 .is_some_and(|score| score < *min_score)
307 {
308 valid = false;
309 }
310 if let CaptchaProvider::CloudflareTurnstile {
311 expected_action,
312 allowed_hostnames,
313 ..
314 }
315 | CaptchaProvider::GoogleRecaptcha {
316 expected_action,
317 allowed_hostnames,
318 ..
319 } = &self.provider
320 {
321 if let Some(action) = expected_action
322 .as_deref()
323 .filter(|action| !action.is_empty())
324 && data.get("action").and_then(Value::as_str) != Some(action)
325 {
326 valid = false;
327 }
328 if !allowed_hostnames.is_empty()
329 && !data
330 .get("hostname")
331 .and_then(Value::as_str)
332 .is_some_and(|hostname| {
333 allowed_hostnames.iter().any(|allowed| allowed == hostname)
334 })
335 {
336 valid = false;
337 }
338 }
339 if valid {
340 Ok(None)
341 } else {
342 failure(403, "VERIFICATION_FAILED", "Captcha verification failed").map(Some)
343 }
344 }
345}
346
347fn failure(status: u16, code: &str, message: &str) -> AuthResult<AuthResponse> {
348 Ok(
349 AuthResponse::json(status, &json!({ "message": message, "code": code }))?
350 .with_header("content-type", "text/plain;charset=UTF-8"),
351 )
352}
353
354#[async_trait]
355impl<S: AuthSchema> AuthPlugin<S> for CaptchaPlugin {
356 fn name(&self) -> &'static str {
357 "captcha"
358 }
359 fn routes(&self) -> Vec<AuthRoute> {
360 Vec::new()
361 }
362 async fn on_http_request(
363 &self,
364 request: &AuthRequest,
365 ctx: &AuthContext<S>,
366 ) -> AuthResult<Option<AuthResponse>> {
367 let pathname = request.url().map_or(request.path(), |url| url.path());
368 let path = path::normalize(pathname, &ctx.config.base_path);
369 let endpoints: Vec<&str> = if self.endpoints.is_empty() {
370 DEFAULT_ENDPOINTS.to_vec()
371 } else {
372 self.endpoints.iter().map(String::as_str).collect()
373 };
374 let result = async {
375 for endpoint in endpoints {
376 if path::matches(endpoint, &path)? {
377 return self
378 .verify(request, ctx.config.advanced.ip_address.resolve(request))
379 .await;
380 }
381 }
382 Ok(None)
383 }
384 .await;
385 match result {
386 Ok(response) => Ok(response),
387 Err(_) => {
388 better_auth_core::observability::logger::current()
389 .error("CAPTCHA verification service or callback failed", &[]);
390 failure(500, "UNKNOWN_ERROR", "Something went wrong").map(Some)
391 }
392 }
393 }
394 async fn on_request(
395 &self,
396 _req: &AuthRequest,
397 _ctx: &AuthContext<S>,
398 ) -> AuthResult<Option<AuthResponse>> {
399 Ok(None)
400 }
401}