signup: optional Cloudflare Turnstile check on account creation
Off unless both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set. When on, createAccount, _account.createPasskeyAccount and SSO complete-registration need a token in the x-turnstile-token header, verified against siteverify (secret, response, remoteip from the rate limiter's client IP, PDS hostname check) before any other work or invite-code use. Siteverify failures fail closed (503 CaptchaUnavailable); missing and rejected tokens are 400 CaptchaRequired and InvalidCaptcha. Requests carrying a createAccount service token from the DID they bring (inbound migration, BYOD did:web) are exempt so goat and PDSMoover keep working; delegated accounts are already authenticated. describeServer gains turnstileSiteKey only when enabled.
720 unmodified linesExpand all72172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476594 unmodified lines720 unmodified linesExpand all## Can also be specified via environment variable `REPORT_SERVICE_DID`.#report_service_did =
[turnstile]# Cloudflare Turnstile site key, shown to the registration pages.# Sign-up captcha is enabled only when both the site key and the# secret key are set.## Can also be specified via environment variable `TURNSTILE_SITE_KEY`.#site_key =
# Cloudflare Turnstile secret key used to verify sign-up tokens.## Can also be specified via environment variable `TURNSTILE_SECRET_KEY`.#secret_key =
# Siteverify endpoint. Only worth changing for tests.## Can also be specified via environment variable `TURNSTILE_SITEVERIFY_URL`.## Default value: "https://challenges.cloudflare.com/turnstile/v0/siteverify"#siteverify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
# Reject tokens solved on a hostname other than the PDS hostname.# Turn off when using Cloudflare's dummy test keys, which report# `example.com`.## Can also be specified via environment variable `TURNSTILE_CHECK_HOSTNAME`.## Default value: true#check_hostname = true
# Siteverify request timeout in milliseconds. Sign-up fails closed# when Cloudflare cannot be reached in time.## Can also be specified via environment variable `TURNSTILE_TIMEOUT_MS`.## Default value: 5000#timeout_ms = 5000
[import]# Whether the PDS accepts repo imports.## Can also be specified via environment variable `ACCEPTING_REPO_IMPORTS`.94 unmodified lines45 unmodified lines4647484950515253545522 unmodified lines45 unmodified linesSo now you have deployed your Tranquil PDS. Welcome!
When you first deploy, and you have invite codes required for your instance, you might ask yourself "ok so how do I make the first invite code?" -> it is there, in the server logs, waiting for you!
If you open sign-ups to everyone, comms-channel verification stays the main line of defence. If you also want a bot check, set `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` (the `[turnstile]` section in `example.toml`) to make the registration pages show a Cloudflare Turnstile challenge. Account creation (password, passkey and SSO) then needs the solved token in an `x-turnstile-token` header, and a Cloudflare outage blocks sign-ups instead of letting them through. Inbound migrations that carry service auth from the DID they bring, and accounts created by a signed-in controller, don't need a token, so goat and PDSMoover keep working. It is off unless both keys are set.
As an admin, there's a special page in the frontend where you can manage accounts. Right now it is quite barebones and honestly if it weren't for the option to delete accounts, we might simply recommend that you use [pds.ls](https://pds.ls).
> 🦪 Lewis>22 unmodified lines150 unmodified linesExpand all151152153154155156157158159160161127 unmodified linesExpand all2892902912922932942952962972982991172 unmodified linesExpand all14721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560461 unmodified linesExpand all2022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210483 unmodified lines150 unmodified linesExpand all #[config(nested)] pub moderation: ModerationConfig,
#[config(nested)] pub turnstile: TurnstileConfig,
#[config(nested)] pub import: ImportConfig,
#[config(nested)] pub scheduled: ScheduledConfig,127 unmodified linesExpand all .to_string(), ); }
// -- turnstile -------------------------------------------------------- self.turnstile.validate(&mut errors);
// -- repo backend ----------------------------------------------------- if let Err(e) = self.storage.repo_backend.parse::<RepoBackend>() { errors.push(e); }1172 unmodified linesExpand all}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct TurnstileConfig { /// Cloudflare Turnstile site key, shown to the registration pages. /// Sign-up captcha is enabled only when both the site key and the /// secret key are set. #[config(env = "TURNSTILE_SITE_KEY")] pub site_key: Option<String>,
/// Cloudflare Turnstile secret key used to verify sign-up tokens. #[config(env = "TURNSTILE_SECRET_KEY")] pub secret_key: Option<String>,
/// Siteverify endpoint. Only worth changing for tests. #[config( env = "TURNSTILE_SITEVERIFY_URL", default = "https://challenges.cloudflare.com/turnstile/v0/siteverify" )] pub siteverify_url: String,
/// Reject tokens solved on a hostname other than the PDS hostname. /// Turn off when using Cloudflare's dummy test keys, which report /// `example.com`. #[config(env = "TURNSTILE_CHECK_HOSTNAME", default = true)] pub check_hostname: bool,
/// Siteverify request timeout in milliseconds. Sign-up fails closed /// when Cloudflare cannot be reached in time. #[config(env = "TURNSTILE_TIMEOUT_MS", default = 5000)] pub timeout_ms: u64,}
/// Empty values count as unset, so `TURNSTILE_SITE_KEY=` in a compose file/// leaves Turnstile off.fn non_blank(value: &Option<String>) -> Option<&str> { value.as_deref().map(str::trim).filter(|v| !v.is_empty())}
/// Site key and secret key of an enabled Turnstile configuration.#[derive(Debug, Clone, Copy)]pub struct TurnstileKeys<'a> { pub site_key: &'a str, pub secret_key: &'a str,}
impl TurnstileConfig { /// The configured keys, or `None` when sign-up captcha is disabled. pub fn keys(&self) -> Option<TurnstileKeys<'_>> { match (non_blank(&self.site_key), non_blank(&self.secret_key)) { (Some(site_key), Some(secret_key)) => Some(TurnstileKeys { site_key, secret_key, }), _ => None, } }
pub fn validate(&self, errors: &mut Vec<String>) { if non_blank(&self.site_key).is_some() != non_blank(&self.secret_key).is_some() { errors.push( "turnstile.site_key (TURNSTILE_SITE_KEY) and turnstile.secret_key \ (TURNSTILE_SECRET_KEY) must both be set or both be unset" .to_string(), ); } if !(self.siteverify_url.starts_with("https://") || self.siteverify_url.starts_with("http://")) { errors.push(format!( "turnstile.siteverify_url (TURNSTILE_SITEVERIFY_URL) must be an http(s) URL, \ got \"{}\"", self.siteverify_url )); } if self.timeout_ms == 0 { errors .push("turnstile.timeout_ms (TURNSTILE_TIMEOUT_MS) must be at least 1".to_string()); } }}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]pub struct ImportConfig { /// Whether the PDS accepts repo imports. #[config(env = "ACCEPTING_REPO_IMPORTS", default = true)] pub accepting: bool,461 unmodified linesExpand all "expected http3 error, got {errors:?}" ); }
fn turnstile_config_for_test( site_key: Option<&str>, secret_key: Option<&str>, ) -> TurnstileConfig { TurnstileConfig { site_key: site_key.map(str::to_string), secret_key: secret_key.map(str::to_string), siteverify_url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_string(), check_hostname: true, timeout_ms: 5000, } }
#[test] fn turnstile_disabled_by_default() { let cfg = turnstile_config_for_test(None, None); let mut errors = Vec::new(); cfg.validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); assert!(cfg.keys().is_none()); }
#[test] fn turnstile_enabled_with_both_keys() { let cfg = turnstile_config_for_test(Some("site"), Some("secret")); let mut errors = Vec::new(); cfg.validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); let keys = cfg.keys().expect("both keys set"); assert_eq!((keys.site_key, keys.secret_key), ("site", "secret")); }
#[test] fn turnstile_validate_rejects_half_configured_keys() { [(Some("site"), None), (None, Some("secret"))] .into_iter() .for_each(|(site, secret)| { let cfg = turnstile_config_for_test(site, secret); let mut errors = Vec::new(); cfg.validate(&mut errors); assert!( errors.iter().any(|e| e.contains("TURNSTILE_SECRET_KEY")), "expected turnstile key error, got {errors:?}" ); assert!(cfg.keys().is_none()); }); }
#[test] fn turnstile_blank_keys_count_as_unset() { let cfg = turnstile_config_for_test(Some(""), Some(" ")); let mut errors = Vec::new(); cfg.validate(&mut errors); assert!(errors.is_empty(), "expected no errors, got {errors:?}"); assert!(cfg.keys().is_none()); }
#[test] fn turnstile_validate_rejects_blank_half_and_bad_url() { let mut cfg = turnstile_config_for_test(Some(" "), Some("secret")); cfg.siteverify_url = "challenges.cloudflare.com".to_string(); let mut errors = Vec::new(); cfg.validate(&mut errors); assert!( errors.iter().any(|e| e.contains("both be set")), "expected half-configured error, got {errors:?}" ); assert!( errors .iter() .any(|e| e.contains("TURNSTILE_SITEVERIFY_URL")), "expected siteverify url error, got {errors:?}" ); }
fn cache_config_for_test( peers: Option<Vec<String>>, cluster_key: Option<&str>, allow_insecure: bool,83 unmodified lines17 unmodified lines181920212223242526831 unmodified linesExpand all85885986086186286386486586610 unmodified lines877878879880881882883884885886499 unmodified linesExpand all17 unmodified lines check_user_rate_limit_with_message,};use tranquil_pds::sso::SsoConfig;use tranquil_pds::state::AppState;use tranquil_pds::turnstile::require_signup_captcha;
fn generate_nonce() -> String { use rand::RngCore; let mut bytes = [0u8; 16];831 unmodified linesExpand all
pub async fn complete_registration( State(state): State<AppState>, rate_limit: RateLimited<AccountCreationLimit>, headers: HeaderMap, Json(input): Json<CompleteRegistrationInput>,) -> Result<Json<CompleteRegistrationResponse>, ApiError> { let client_ip = rate_limit.client_ip(); use jacquard_common::types::{integer::LimitedU32, string::Tid};10 unmodified lines if input.handle.len() > 100 { return Err(ApiError::InvalidHandle(None)); }
require_signup_captcha(&headers, client_ip).await?;
let pending_preview = state .repos .sso .get_pending_registration(&input.token)499 unmodified linesExpand all22 unmodified lines232425262728293031198 unmodified linesExpand all22 unmodified linespub mod sso;pub mod state;pub mod storage;pub mod sync;pub mod turnstile;pub mod types;pub mod util;pub mod validation;
198 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327//! Optional Cloudflare Turnstile check for public account sign-up.//!//! When `turnstile.site_key` and `turnstile.secret_key` are configured, the//! sign-up endpoints require a Turnstile token in the [`TOKEN_HEADER`] header//! and verify it against Cloudflare's siteverify endpoint before doing any//! other work. A header keeps the `com.atproto.server.createAccount` input//! schema untouched for other clients. Verification fails closed: a//! siteverify outage blocks sign-up rather than letting bots through.
use crate::api::error::ApiError;use axum::http::HeaderMap;use reqwest::Client;use serde::{Deserialize, Serialize};use std::net::IpAddr;use std::sync::OnceLock;use std::time::Duration;use tracing::{error, warn};
/// Request header carrying the Turnstile response token.pub const TOKEN_HEADER: &str = "x-turnstile-token";
/// Turnstile tokens are at most 2048 characters long.const MAX_TOKEN_LEN: usize = 2048;
static CLIENT: OnceLock<Client> = OnceLock::new();
fn client() -> &'static Client { CLIENT.get_or_init(|| { Client::builder() .redirect(reqwest::redirect::Policy::none()) .build() .expect("Failed to build Turnstile HTTP client") })}
/// Where and how to verify a token.#[derive(Debug, Clone)]pub struct Siteverify<'a> { pub url: &'a str, pub secret: &'a str, pub expected_hostname: Option<&'a str>, pub timeout: Duration,}
#[derive(Debug, Clone, PartialEq, Eq)]pub enum VerifyError { /// Cloudflare looked at the token and said no. Rejected(Vec<String>), /// The answer could not be obtained or trusted; sign-up fails closed. Unavailable(String),}
#[derive(Serialize)]struct SiteverifyRequest<'a> { secret: &'a str, response: &'a str, #[serde(skip_serializing_if = "Option::is_none")] remoteip: Option<String>,}
#[derive(Deserialize)]struct SiteverifyResponse { success: bool, #[serde(default, rename = "error-codes")] error_codes: Vec<String>, hostname: Option<String>,}
/// Error codes that describe our configuration or Cloudflare's health rather/// than the visitor's token.fn is_operator_error(code: &str) -> bool { matches!( code, "missing-input-secret" | "invalid-input-secret" | "internal-error" )}
/// Ask siteverify about `token`. `remote_ip` is forwarded only when it parses/// as an IP address.pub async fn siteverify( params: &Siteverify<'_>, token: &str, remote_ip: &str,) -> Result<(), VerifyError> { let body = SiteverifyRequest { secret: params.secret, response: token, remoteip: remote_ip.parse::<IpAddr>().ok().map(|ip| ip.to_string()), }; let response = client() .post(params.url) .timeout(params.timeout) .json(&body) .send() .await .map_err(|e| VerifyError::Unavailable(format!("request failed: {e}")))?; if !response.status().is_success() { return Err(VerifyError::Unavailable(format!( "siteverify returned HTTP {}", response.status() ))); } let outcome: SiteverifyResponse = response .json() .await .map_err(|e| VerifyError::Unavailable(format!("unreadable response: {e}")))?; if !outcome.success { return Err( if outcome.error_codes.iter().any(|c| is_operator_error(c)) { VerifyError::Unavailable(format!( "siteverify error codes {:?}", outcome.error_codes )) } else { VerifyError::Rejected(outcome.error_codes) }, ); } match params.expected_hostname { Some(expected) if outcome.hostname.as_deref() != Some(expected) => { Err(VerifyError::Rejected(vec!["hostname-mismatch".to_string()])) } _ => Ok(()), }}
fn token_from_headers(headers: &HeaderMap) -> Option<&str> { headers .get(TOKEN_HEADER) .and_then(|v| v.to_str().ok()) .map(str::trim) .filter(|t| !t.is_empty())}
/// Enforce the sign-up captcha for a brand-new account created by an/// unauthenticated request. A no-op when Turnstile is not configured.pub async fn require_signup_captcha(headers: &HeaderMap, client_ip: &str) -> Result<(), ApiError> { let cfg = tranquil_config::get(); let Some(keys) = cfg.turnstile.keys() else { return Ok(()); }; let token = token_from_headers(headers).ok_or(ApiError::CaptchaRequired)?; if token.len() > MAX_TOKEN_LEN { return Err(ApiError::InvalidCaptcha); } let params = Siteverify { url: &cfg.turnstile.siteverify_url, secret: keys.secret_key, expected_hostname: cfg .turnstile .check_hostname .then(|| cfg.server.hostname_without_port()), timeout: Duration::from_millis(cfg.turnstile.timeout_ms), }; siteverify(¶ms, token, client_ip) .await .map_err(|e| match e { VerifyError::Rejected(codes) => { warn!(?codes, "Turnstile rejected sign-up token"); ApiError::InvalidCaptcha } VerifyError::Unavailable(reason) => { error!(%reason, "Turnstile verification unavailable, refusing sign-up"); ApiError::CaptchaUnavailable } })}
#[cfg(test)]mod tests { use super::*; use axum::{Json, Router, routing::post}; use serde_json::{Value, json}; use tokio::net::TcpListener;
async fn serve(router: Router) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); format!("http://{addr}/siteverify") }
async fn mock(answer: Value) -> String { serve(Router::new().route( "/siteverify", post(move || { let answer = answer.clone(); async move { Json(answer) } }), )) .await }
fn params(url: &str) -> Siteverify<'_> { Siteverify { url, secret: "secret", expected_hostname: Some("pds.example"), timeout: Duration::from_millis(500), } }
#[tokio::test] async fn accepts_successful_token_on_expected_hostname() { let url = mock(json!({"success": true, "hostname": "pds.example"})).await; assert_eq!( siteverify(¶ms(&url), "tok", "203.0.113.7").await, Ok(()) ); }
#[tokio::test] async fn forwards_secret_token_and_ip() { let (tx, rx) = tokio::sync::oneshot::channel::<Value>(); let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); let url = serve(Router::new().route( "/siteverify", post(move |Json(body): Json<Value>| { let sender = tx.lock().unwrap().take(); async move { if let Some(sender) = sender { let _ = sender.send(body); } Json(json!({"success": true, "hostname": "pds.example"})) } }), )) .await; siteverify(¶ms(&url), "tok", "203.0.113.7") .await .unwrap(); assert_eq!( rx.await.unwrap(), json!({"secret": "secret", "response": "tok", "remoteip": "203.0.113.7"}) ); }
#[tokio::test] async fn rejects_failed_token() { let url = mock(json!({"success": false, "error-codes": ["invalid-input-response"]})).await; assert_eq!( siteverify(¶ms(&url), "tok", "unknown").await, Err(VerifyError::Rejected(vec!["invalid-input-response".into()])) ); }
#[tokio::test] async fn rejects_token_from_other_hostname() { let url = mock(json!({"success": true, "hostname": "evil.example"})).await; assert_eq!( siteverify(¶ms(&url), "tok", "unknown").await, Err(VerifyError::Rejected(vec!["hostname-mismatch".into()])) ); let unchecked = Siteverify { expected_hostname: None, ..params(&url) }; assert_eq!(siteverify(&unchecked, "tok", "unknown").await, Ok(())); }
#[tokio::test] async fn bad_secret_is_unavailable_not_rejected() { let url = mock(json!({"success": false, "error-codes": ["invalid-input-secret"]})).await; assert!(matches!( siteverify(¶ms(&url), "tok", "unknown").await, Err(VerifyError::Unavailable(_)) )); }
#[tokio::test] async fn fails_closed_when_unreachable() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}/siteverify", listener.local_addr().unwrap()); drop(listener); assert!(matches!( siteverify(¶ms(&url), "tok", "unknown").await, Err(VerifyError::Unavailable(_)) )); }
#[tokio::test] async fn fails_closed_on_server_error_and_garbage() { let error_url = serve(Router::new().route( "/siteverify", post(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }), )) .await; let garbage_url = serve(Router::new().route("/siteverify", post(|| async { "<html>" }))).await; for url in [error_url, garbage_url] { assert!(matches!( siteverify(¶ms(&url), "tok", "unknown").await, Err(VerifyError::Unavailable(_)) )); } }
#[tokio::test] async fn fails_closed_on_timeout() { let url = serve(Router::new().route( "/siteverify", post(|| async { tokio::time::sleep(Duration::from_secs(5)).await; Json(json!({"success": true, "hostname": "pds.example"})) }), )) .await; let quick = Siteverify { timeout: Duration::from_millis(100), ..params(&url) }; assert!(matches!( siteverify(&quick, "tok", "unknown").await, Err(VerifyError::Unavailable(_)) )); }
#[test] fn reads_trimmed_token_header() { let mut headers = HeaderMap::new(); assert_eq!(token_from_headers(&headers), None); headers.insert(TOKEN_HEADER, " ".parse().unwrap()); assert_eq!(token_from_headers(&headers), None); headers.insert(TOKEN_HEADER, " abc ".parse().unwrap()); assert_eq!(token_from_headers(&headers), Some("abc")); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340//! Sign-up captcha enforcement with Turnstile enabled, against a local//! siteverify stand-in. Lives in its own test binary because the config is//! process-global and every other suite runs with Turnstile disabled.
mod common;use axum::{Json, Router, http::StatusCode as AxumStatus, response::IntoResponse, routing::post};use common::*;use k256::ecdsa::SigningKey;use reqwest::StatusCode;use serde_json::{Value, json};use std::sync::{Mutex, OnceLock};use std::time::Duration;use tranquil_pds::turnstile::TOKEN_HEADER;
const SITE_KEY: &str = "test-site-key";const SECRET_KEY: &str = "test-secret-key";const CLIENT_IP: &str = "203.0.113.7";
static SITEVERIFY_URL: OnceLock<String> = OnceLock::new();static SITEVERIFY_CALLS: Mutex<Vec<Value>> = Mutex::new(Vec::new());
/// Answers like Cloudflare would, keyed on the submitted token.async fn fake_siteverify(Json(body): Json<Value>) -> axum::response::Response { SITEVERIFY_CALLS.lock().unwrap().push(body.clone()); match body["response"].as_str().unwrap_or_default() { token if token.starts_with("pass") => { Json(json!({"success": true, "hostname": "pds.test"})).into_response() } "other-host" => Json(json!({"success": true, "hostname": "evil.example"})).into_response(), "server-error" => AxumStatus::INTERNAL_SERVER_ERROR.into_response(), "slow" => { tokio::time::sleep(Duration::from_secs(3)).await; Json(json!({"success": true, "hostname": "pds.test"})).into_response() } _ => Json(json!({"success": false, "error-codes": ["invalid-input-response"]})) .into_response(), }}
fn start_siteverify() -> String { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async move { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}/siteverify", listener.local_addr().unwrap()); tx.send(url).unwrap(); let app = Router::new().route("/siteverify", post(fake_siteverify)); axum::serve(listener, app).await.unwrap(); }); }); rx.recv().expect("siteverify stand-in failed to start")}
async fn turnstile_base_url() -> &'static str { SITEVERIFY_URL.get_or_init(|| { let url = start_siteverify(); unsafe { std::env::set_var("TURNSTILE_SITE_KEY", SITE_KEY); std::env::set_var("TURNSTILE_SECRET_KEY", SECRET_KEY); std::env::set_var("TURNSTILE_SITEVERIFY_URL", &url); std::env::set_var("TURNSTILE_TIMEOUT_MS", "500"); } url }); base_url().await}
fn calls_with_token(token: &str) -> Vec<Value> { SITEVERIFY_CALLS .lock() .unwrap() .iter() .filter(|c| c["response"] == token) .cloned() .collect()}
fn unique_handle(prefix: &str) -> String { format!( "{prefix}{}", &uuid::Uuid::new_v4().simple().to_string()[..10] )}
fn account_payload(handle: &str) -> Value { json!({ "handle": handle, "email": format!("{handle}@example.com"), "password": "Testpass123!" })}
async fn create_account(payload: &Value, token: Option<&str>) -> (StatusCode, Value) { let url = turnstile_base_url().await; let request = client() .post(format!("{url}/xrpc/com.atproto.server.createAccount")) .header("x-forwarded-for", CLIENT_IP) .json(payload); let request = match token { Some(t) => request.header(TOKEN_HEADER, t), None => request, }; let res = request.send().await.expect("createAccount request failed"); let status = res.status(); (status, res.json().await.unwrap_or(Value::Null))}
async fn handle_resolves(handle: &str) -> bool { let url = turnstile_base_url().await; let full = format!("{handle}.{}", pds_hostname().split(':').next().unwrap()); client() .get(format!("{url}/xrpc/com.atproto.identity.resolveHandle")) .query(&[("handle", full)]) .send() .await .expect("resolveHandle request failed") .status() == StatusCode::OK}
#[tokio::test]async fn describe_server_advertises_site_key() { let url = turnstile_base_url().await; let body: Value = client() .get(format!("{url}/xrpc/com.atproto.server.describeServer")) .send() .await .unwrap() .json() .await .unwrap(); assert_eq!(body["turnstileSiteKey"], SITE_KEY);}
#[tokio::test]async fn create_account_without_token_is_refused() { let handle = unique_handle("nt"); let (status, body) = create_account(&account_payload(&handle), None).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "CaptchaRequired"); assert!(body["message"].as_str().is_some_and(|m| !m.is_empty())); assert!(!handle_resolves(&handle).await, "account must not exist");}
#[tokio::test]async fn create_account_with_rejected_token_is_refused() { let handle = unique_handle("rt"); let (status, body) = create_account(&account_payload(&handle), Some("rejected")).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "InvalidCaptcha"); assert!(!handle_resolves(&handle).await, "account must not exist");}
#[tokio::test]async fn create_account_with_token_from_other_hostname_is_refused() { let handle = unique_handle("oh"); let (status, body) = create_account(&account_payload(&handle), Some("other-host")).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "InvalidCaptcha");}
#[tokio::test]async fn create_account_fails_closed_when_siteverify_is_broken() { for token in ["server-error", "slow"] { let handle = unique_handle("fc"); let (status, body) = create_account(&account_payload(&handle), Some(token)).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{token}: {body}"); assert_eq!(body["error"], "CaptchaUnavailable"); assert!(!handle_resolves(&handle).await, "account must not exist"); }}
#[tokio::test]async fn create_account_with_valid_token_succeeds() { let handle = unique_handle("ok"); let token = format!("pass-{handle}"); let (status, body) = create_account(&account_payload(&handle), Some(&token)).await; assert_eq!(status, StatusCode::OK, "{body}"); assert!( body["did"] .as_str() .is_some_and(|d| d.starts_with("did:plc:")) ); assert_eq!( calls_with_token(&token), vec![json!({"secret": SECRET_KEY, "response": token, "remoteip": CLIENT_IP})] );}
#[tokio::test]async fn passkey_account_requires_token() { let url = turnstile_base_url().await; let send = |token: Option<String>| async move { let handle = unique_handle("pk"); let request = client() .post(format!("{url}/xrpc/_account.createPasskeyAccount")) .json(&json!({"handle": handle, "email": format!("{handle}@example.com")})); let request = match token { Some(t) => request.header(TOKEN_HEADER, t), None => request, }; let res = request.send().await.unwrap(); let status = res.status(); (status, res.json::<Value>().await.unwrap_or(Value::Null)) }; let (status, body) = send(None).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "CaptchaRequired"); let (status, body) = send(Some("pass-passkey".into())).await; assert_eq!(status, StatusCode::OK, "{body}"); assert!(body["setupToken"].is_string());}
#[tokio::test]async fn sso_registration_requires_token_before_anything_else() { let url = turnstile_base_url().await; let send = |token: Option<&'static str>| async move { let request = client() .post(format!("{url}/oauth/sso/complete-registration")) .json(&json!({"token": "nonexistent_sso_token", "handle": "ssouser"})); let request = match token { Some(t) => request.header(TOKEN_HEADER, t), None => request, }; let res = request.send().await.unwrap(); let status = res.status(); (status, res.json::<Value>().await.unwrap_or(Value::Null)) }; let (status, body) = send(None).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "CaptchaRequired"); let (status, body) = send(Some("rejected")).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!(body["error"], "InvalidCaptcha"); let (status, body) = send(Some("pass-sso")).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!( body["error"], "SsoSessionExpired", "a passing captcha reaches the normal SSO checks" );}
/// Publish a did:plc in the mock PLC directory and mint a createAccount/// service token signed by its key, the way an outgoing PDS (or goat or/// pdsmoover) does for a migration.async fn migrating_identity(handle: &str) -> (String, String) { let signing_key = SigningKey::random(&mut rand::thread_rng()); let genesis = tranquil_pds::plc::create_genesis_operation( &signing_key, None, &format!("{handle}.old-pds.example").parse().unwrap(), "https://old-pds.example", ) .expect("genesis operation"); let plc_url = std::env::var("PLC_DIRECTORY_URL").expect("mock PLC url"); let res = client() .post(format!("{plc_url}/{}", genesis.did)) .json(&genesis.signed_operation) .send() .await .unwrap(); assert!(res.status().is_success(), "mock PLC refused genesis op"); let aud = format!("did:web:{}", pds_hostname()); let lxm = tranquil_types::Nsid::new("com.atproto.server.createAccount").unwrap(); let token = tranquil_pds::auth::create_service_token( &genesis.did, &tranquil_types::DidRef::new(&aud).unwrap(), Some(&lxm), None, &signing_key.to_bytes(), ) .expect("service token"); (genesis.did.to_string(), token)}
#[tokio::test]async fn migration_with_service_auth_needs_no_captcha() { let url = turnstile_base_url().await; let handle = unique_handle("mg"); let (did, service_token) = migrating_identity(&handle).await; let mut payload = account_payload(&handle); payload["did"] = json!(did); let res = client() .post(format!("{url}/xrpc/com.atproto.server.createAccount")) .bearer_auth(&service_token) .json(&payload) .send() .await .unwrap(); let status = res.status(); let body: Value = res.json().await.unwrap_or(Value::Null); assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["did"], did);}
#[tokio::test]async fn service_auth_for_a_different_purpose_does_not_exempt() { let url = turnstile_base_url().await; let handle = unique_handle("sa"); let (_did, service_token) = migrating_identity(&handle).await; let res = client() .post(format!("{url}/xrpc/com.atproto.server.createAccount")) .bearer_auth(&service_token) .json(&account_payload(&handle)) .send() .await .unwrap(); let status = res.status(); let body: Value = res.json().await.unwrap_or(Value::Null); assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); assert_eq!( body["error"], "CaptchaRequired", "a service token without the matching did is not a migration" );}
#[tokio::test]async fn delegated_account_created_by_signed_in_controller_needs_no_captcha() { let url = turnstile_base_url().await; let http = client(); let handle = unique_handle("ct"); let (status, body) = create_account(&account_payload(&handle), Some(&format!("pass-{handle}"))).await; assert_eq!(status, StatusCode::OK, "{body}"); let controller_jwt = verify_new_account(&http, body["did"].as_str().unwrap()).await; let res = http .post(format!("{url}/xrpc/_delegation.createDelegatedAccount")) .bearer_auth(&controller_jwt) .json(&json!({ "handle": unique_handle("dl"), "controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES })) .send() .await .unwrap(); let status = res.status(); let body: Value = res.json().await.unwrap_or(Value::Null); assert_eq!(status, StatusCode::OK, "{body}");}12 unmodified lines131415161718192021151 unmodified linesExpand all17317417517617617717817918018179 unmodified lines261262263264265266267268269270271272273274275276277278279307 unmodified linesExpand all12 unmodified linesuse tranquil_pds::api::invite::check_registration_invite;use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};use tranquil_pds::state::AppState;use tranquil_pds::turnstile::require_signup_captcha;use tranquil_pds::types::{CidLink, Did, Handle, Nsid, PlainPassword};use tranquil_pds::validation::validate_password;#[derive(Deserialize)]#[serde(rename_all = "camelCase")]151 unmodified linesExpand all}
pub async fn create_account( State(state): State<AppState>, _rate_limit: RateLimited<AccountCreationLimit>, rate_limit: RateLimited<AccountCreationLimit>, headers: HeaderMap, Json(input): Json<CreateAccountInput>,) -> Response { let is_potential_migration = input79 unmodified lines ); } }
// Inbound migrations and BYOD did:web carry a service token signed by the // DID being brought in (matched against `did` above). Migration tools // cannot render a captcha, so only brand-new identities need one. let captcha_exempt = (is_migration || is_did_web_byod) && input.did_type.as_deref() != Some("web"); if !captcha_exempt && let Err(e) = require_signup_captcha(&headers, rate_limit.client_ip()).await { return e.into_response(); }
let cfg = tranquil_config::get(); let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { Ok(h) => h, Err(e) => return ApiError::from(e).into_response(),307 unmodified linesExpand all