Skip to main content

better_auth_api/plugins/
captcha.rs

1//! CAPTCHA verification before endpoint parsing, authentication, and application hooks.
2
3mod 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/// A BotID verdict supplied by the application's Vercel integration.
24#[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/// Call the application's BotID SDK for the current request.
37#[async_trait]
38pub trait BotIdChecker: Send + Sync {
39    async fn check(&self) -> AuthResult<BotIdVerification>;
40}
41
42/// Override the default decision that only `is_bot == false` is accepted.
43#[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/// Provider-specific options. Secrets are never included in plugin diagnostics.
53#[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/// Protect HTTP authentication endpoints. Native API calls bypass this plugin.
149#[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    /// Protect exact paths or wildcard patterns. An empty list selects the upstream defaults.
168    pub fn endpoints(mut self, endpoints: Vec<String>) -> Self {
169        self.endpoints = endpoints;
170        self
171    }
172
173    /// Use a provider-compatible verification service. An empty URL selects the provider default.
174    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            // Promise.race does not cancel the application callback when its deadline expires.
194            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        // better-fetch clears its timer after fetch resolves, before reading the response body.
266        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        // better-fetch keeps non-JSON text as a string; a nonempty string fails verification.
297        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}