···1[package]
2name = "pds_gatekeeper"
3-version = "0.1.0"
4edition = "2024"
5license = "MIT"
6···15tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
16hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
17tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
18-tower_governor = "0.8.0"
19hex = "0.4"
20jwt-compact = { version = "0.8.0", features = ["es256k"] }
21scrypt = "0.11"
22-#Leaveing these two cause I think it is needed by the
23aws-lc-rs = "1.13.0"
24rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
25lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
···28axum-template = { version = "3.0.0", features = ["handlebars"] }
29rand = "0.9.2"
30anyhow = "1.0.99"
31-chrono = "0.4.41"
32sha2 = "0.10"
0000000
···1[package]
2name = "pds_gatekeeper"
3+version = "0.1.2"
4edition = "2024"
5license = "MIT"
6···15tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
16hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
17tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
18+tower_governor = { version = "0.8.0", features = ["axum", "tracing"] }
19hex = "0.4"
20jwt-compact = { version = "0.8.0", features = ["es256k"] }
21scrypt = "0.11"
22+#Leaveing these two cause I think it is needed by the email crate for ssl
23aws-lc-rs = "1.13.0"
24rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
25lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
···28axum-template = { version = "3.0.0", features = ["handlebars"] }
29rand = "0.9.2"
30anyhow = "1.0.99"
31+chrono = { version = "0.4.42", features = ["default", "serde"] }
32sha2 = "0.10"
33+jacquard-common = "0.9.2"
34+jacquard-identity = "0.9.2"
35+multibase = "0.9.2"
36+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
37+urlencoding = "2.1"
38+html-escape = "0.2.13"
39+josekit = "0.10.3"
+109-24
README.md
···15- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
1718-## Captcha on Create Account
1920-Future feature?
0000000000000000000000000002122# Setup
23···37```yml
38 gatekeeper:
39 container_name: gatekeeper
40- image: fatfingers23/pds_gatekeeper:arm-latest
41 network_mode: host
42 restart: unless-stopped
43 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···49 - pds
50```
5100000000000000000000000000000000000000000000052## Caddy setup
5354For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
55in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
56This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
5758-```caddyfile
59 @gatekeeper {
60- path /xrpc/com.atproto.server.getSession
61- path /xrpc/com.atproto.server.updateEmail
62- path /xrpc/com.atproto.server.createSession
63- path /@atproto/oauth-provider/~api/sign-in
00064 }
6566 handle @gatekeeper {
67- reverse_proxy http://localhost:8080
68- }
6970- reverse_proxy http://localhost:3000
71```
7273If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
74`localhost:8081` (or w/e port you want).
7576-```caddyfile
77http://*.localhost:8082, http://localhost:8082 {
78- @gatekeeper {
79- path /xrpc/com.atproto.server.getSession
80- path /xrpc/com.atproto.server.updateEmail
81- path /xrpc/com.atproto.server.createSession
82- path /@atproto/oauth-provider/~api/sign-in
83- }
84-85- handle @gatekeeper {
86- reverse_proxy http://localhost:8080
87- }
8889- reverse_proxy http://localhost:3000
00000090}
9192```
···119120`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
121the 5 comes from. Example can set this to 10 to allow for 10 requests in a burst, and after 60 seconds it will drop one
122-off. 00000
···15- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
1718+## Captcha on account creation
1920+Require a `verificationCode` set on the `createAccount` request. This is gotten from completing a captcha challenge
21+hosted on the
22+PDS mimicking what the Bluesky Entryway does. Migration tools will need to support this, but social-apps will support
23+and redirect to `GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT`. This is how the clients know to get the code to prove a captcha
24+was successful.
25+26+- Requires `GATEKEEPER_CREATE_ACCOUNT_CAPTCHA` to be set to true.
27+- Requires `PDS_HCAPTCHA_SITE_KEY` and `PDS_HCAPTCHA_SECRET_KEY` to be set. Can sign up at https://www.hcaptcha.com/
28+- Requires proxying `/xrpc/com.atproto.server.describeServer`, `/xrpc/com.atproto.server.createAccount` and `/gate/*` to
29+ PDS
30+ Gatekeeper
31+- Optional `GATEKEEPER_JWE_KEY` key to encrypt the captcha verification code. Defaults to a random 32 byte key. Not
32+ strictly needed unless you're scaling
33+- Optional`GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT` default redirect on captcha success. Defaults to `https://bsky.app`.
34+- Optional `GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS` allowed redirect urls for captcha success. You want these to match the
35+ url showing the captcha. Defaults are:
36+ - https://bsky.app
37+ - https://pdsmoover.com
38+ - https://blacksky.community
39+ - https://tektite.cc
40+41+## Block account creation unless it's a migration
42+43+You can set `GATEKEEPER_ALLOW_ONLY_MIGRATIONS` to block createAccount unless it's via a migration. This does not require
44+a change for migration tools, but social-apps create a new account will no longer work and to create a brand new account
45+users will need to do this via the Oauth account create screen on the PDS. We recommend setting `PDS_HCAPTCHA_SITE_KEY`
46+and `PDS_HCAPTCHA_SECRET_KEY` so the OAuth screen is protected by a captcha if you use this with invite codes turned
47+off.
4849# Setup
50···64```yml
65 gatekeeper:
66 container_name: gatekeeper
67+ image: fatfingers23/pds_gatekeeper:latest
68 network_mode: host
69 restart: unless-stopped
70 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···76 - pds
77```
7879+For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up
80+correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml).
81+82+```yml
83+gatekeeper:
84+ container_name: gatekeeper
85+ image: 'fatfingers23/pds_gatekeeper:latest'
86+ restart: unless-stopped
87+ volumes:
88+ - '/pds:/pds'
89+ environment:
90+ - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
91+ - 'PDS_BASE_URL=http://pds:3000'
92+ - GATEKEEPER_HOST=0.0.0.0
93+ depends_on:
94+ - pds
95+ healthcheck:
96+ test:
97+ - CMD
98+ - timeout
99+ - '1'
100+ - bash
101+ - '-c'
102+ - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
103+ interval: 10s
104+ timeout: 5s
105+ retries: 3
106+ start_period: 10s
107+ labels:
108+ - traefik.enable=true
109+ - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))'
110+ - traefik.http.routers.pds-gatekeeper.entrypoints=https
111+ - traefik.http.routers.pds-gatekeeper.tls=true
112+ - traefik.http.routers.pds-gatekeeper.priority=100
113+ - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
114+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
115+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
116+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
117+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
118+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
119+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
120+ - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
121+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
122+```
123+124## Caddy setup
125126For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
127in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
128This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
129130+```
131 @gatekeeper {
132+ path /xrpc/com.atproto.server.getSession
133+ path /xrpc/com.atproto.server.describeServer
134+ path /xrpc/com.atproto.server.updateEmail
135+ path /xrpc/com.atproto.server.createSession
136+ path /xrpc/com.atproto.server.createAccount
137+ path /@atproto/oauth-provider/~api/sign-in
138+ path /gate/*
139 }
140141 handle @gatekeeper {
142+ reverse_proxy http://localhost:8080
143+ }
144145+ reverse_proxy http://localhost:3000
146```
147148If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
149`localhost:8081` (or w/e port you want).
150151+```
152http://*.localhost:8082, http://localhost:8082 {
153+ @gatekeeper {
154+ path /xrpc/com.atproto.server.getSession
155+ path /xrpc/com.atproto.server.describeServer
156+ path /xrpc/com.atproto.server.updateEmail
157+ path /xrpc/com.atproto.server.createSession
158+ path /xrpc/com.atproto.server.createAccount
159+ path /@atproto/oauth-provider/~api/sign-in
160+ path /gate/*
161+ }
0162163+ handle @gatekeeper {
164+ #This is the address for PDS gatekeeper, default is 8080
165+ reverse_proxy http://localhost:8080
166+ #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper
167+ header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
168+ }
169+ reverse_proxy http://localhost:3000
170}
171172```
···199200`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
201the 5 comes from. Example can set this to 10 to allow for 10 requests in a burst, and after 60 seconds it will drop one
202+off.
203+204+`GATEKEEPER_ALLOW_ONLY_MIGRATIONS` - Defaults false. If set to true, will only allow the
205+`/xrpc/com.atproto.server.createAccount` endpoint to be used for migrations. Meaning it will check for the serviceAuth
206+token and verify it is valid.
207+
+22-22
examples/Caddyfile
···1{
2- email youremail@myemail.com
3- on_demand_tls {
4- ask http://localhost:3000/tls-check
5- }
6}
78*.yourpds.com, yourpds.com {
9- tls {
10- on_demand
11- }
12- # You'll most likely just want from here to....
13- @gatekeeper {
14- path /xrpc/com.atproto.server.getSession
15- path /xrpc/com.atproto.server.updateEmail
16- path /xrpc/com.atproto.server.createSession
17- path /xrpc/com.atproto.server.createAccount
18- path /@atproto/oauth-provider/~api/sign-in
19 }
00000000002021- handle @gatekeeper {
22- #This is the address for PDS gatekeeper, default is 8080
23- reverse_proxy http://localhost:8080
24- }
2526- reverse_proxy http://localhost:3000
27- #..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
28}
29-30-
···1{
2+ email youremail@myemail.com
3+ on_demand_tls {
4+ ask http://localhost:3000/tls-check
5+ }
6}
78*.yourpds.com, yourpds.com {
9+ tls {
10+ on_demand
0000000011 }
12+# You'll most likely just want from here to....
13+ @gatekeeper {
14+ path /xrpc/com.atproto.server.getSession
15+ path /xrpc/com.atproto.server.describeServer
16+ path /xrpc/com.atproto.server.updateEmail
17+ path /xrpc/com.atproto.server.createSession
18+ path /xrpc/com.atproto.server.createAccount
19+ path /@atproto/oauth-provider/~api/sign-in
20+ path /gate/*
21+ }
2223+ handle @gatekeeper {
24+ #This is the address for PDS gatekeeper, default is 8080
25+ reverse_proxy http://localhost:8080
26+ }
2728+ reverse_proxy http://localhost:3000
29+#..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
30}
00
+1-1
examples/compose.yml
···39 WATCHTOWER_SCHEDULE: "@midnight"
40 gatekeeper:
41 container_name: gatekeeper
42- image: fatfingers23/pds_gatekeeper:arm-latest
43 network_mode: host
44 restart: unless-stopped
45 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···39 WATCHTOWER_SCHEDULE: "@midnight"
40 gatekeeper:
41 container_name: gatekeeper
42+ image: fatfingers23/pds_gatekeeper:latest
43 network_mode: host
44 restart: unless-stopped
45 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···1+-- Add migration script here
2+CREATE TABLE IF NOT EXISTS gate_codes
3+(
4+ code VARCHAR PRIMARY KEY,
5+ handle VARCHAR NOT NULL,
6+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
7+);
8+9+-- Index on created_at for efficient cleanup of expired codes
10+CREATE INDEX IF NOT EXISTS idx_gate_codes_created_at ON gate_codes(created_at);
···1use crate::AppState;
2use crate::helpers::{
3- AuthResult, ProxiedResult, TokenCheckError, json_error_response, preauth_check, proxy_get_json,
04};
5use crate::middleware::Did;
6-use axum::body::Body;
7use axum::extract::State;
8-use axum::http::{HeaderMap, StatusCode};
9use axum::response::{IntoResponse, Response};
10use axum::{Extension, Json, debug_handler, extract, extract::Request};
0011use serde::{Deserialize, Serialize};
12use serde_json;
13use tracing::log;
···61 allow_takendown: Option<bool>,
62}
6300000000000000000000000000000000000000000000000000064pub async fn create_session(
65 State(state): State<AppState>,
66 headers: HeaderMap,
···90 //No 2FA or already passed
91 let uri = format!(
92 "{}{}",
93- state.pds_base_url, "/xrpc/com.atproto.server.createSession"
94 );
9596 let mut req = axum::http::Request::post(uri);
···230 // Updating the actual email address by sending it on to the PDS
231 let uri = format!(
232 "{}{}",
233- state.pds_base_url, "/xrpc/com.atproto.server.updateEmail"
234 );
235 let mut req = axum::http::Request::post(uri);
236 if let Some(req_headers) = req.headers_mut() {
···283 }
284}
2850000000000000000000000000000000000000000000000000000000000000000000000000000000000000286pub async fn create_account(
287 State(state): State<AppState>,
288- mut req: Request,
289) -> Result<Response<Body>, StatusCode> {
290- //TODO if I add the block of only accounts authenticated just take the body as json here and grab the lxm token. No middle ware is needed
000000000000000000000000000000000000000000000000000000000000000000029100000000000000000000000000000000000000000000000000000000000000000000000000000000000292 let uri = format!(
293 "{}{}",
294- state.pds_base_url, "/xrpc/com.atproto.server.createAccount"
295 );
296297- // Rewrite the URI to point at the upstream PDS; keep headers, method, and body intact
298- *req.uri_mut() = uri.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
000000299300 let proxied = state
301 .reverse_proxy_client
302- .request(req)
303 .await
304 .map_err(|_| StatusCode::BAD_REQUEST)?
305 .into_response();
···1use crate::AppState;
2use crate::helpers::{
3+ AuthResult, ProxiedResult, TokenCheckError, VerifyServiceAuthError, json_error_response,
4+ preauth_check, proxy_get_json, verify_gate_token, verify_service_auth,
5};
6use crate::middleware::Did;
7+use axum::body::{Body, to_bytes};
8use axum::extract::State;
9+use axum::http::{HeaderMap, StatusCode, header};
10use axum::response::{IntoResponse, Response};
11use axum::{Extension, Json, debug_handler, extract, extract::Request};
12+use chrono::{Duration, Utc};
13+use jacquard_common::types::did::Did as JacquardDid;
14use serde::{Deserialize, Serialize};
15use serde_json;
16use tracing::log;
···64 allow_takendown: Option<bool>,
65}
6667+#[derive(Deserialize, Serialize, Debug)]
68+#[serde(rename_all = "camelCase")]
69+pub struct CreateAccountRequest {
70+ handle: String,
71+ #[serde(skip_serializing_if = "Option::is_none")]
72+ email: Option<String>,
73+ #[serde(skip_serializing_if = "Option::is_none")]
74+ password: Option<String>,
75+ #[serde(skip_serializing_if = "Option::is_none")]
76+ did: Option<String>,
77+ #[serde(skip_serializing_if = "Option::is_none")]
78+ invite_code: Option<String>,
79+ #[serde(skip_serializing_if = "Option::is_none")]
80+ verification_code: Option<String>,
81+ #[serde(skip_serializing_if = "Option::is_none")]
82+ plc_op: Option<serde_json::Value>,
83+}
84+85+#[derive(Deserialize, Serialize, Debug, Clone)]
86+#[serde(rename_all = "camelCase")]
87+pub struct DescribeServerContact {
88+ #[serde(skip_serializing_if = "Option::is_none")]
89+ email: Option<String>,
90+}
91+92+#[derive(Deserialize, Serialize, Debug, Clone)]
93+#[serde(rename_all = "camelCase")]
94+pub struct DescribeServerLinks {
95+ #[serde(skip_serializing_if = "Option::is_none")]
96+ privacy_policy: Option<String>,
97+ #[serde(skip_serializing_if = "Option::is_none")]
98+ terms_of_service: Option<String>,
99+}
100+101+#[derive(Deserialize, Serialize, Debug, Clone)]
102+#[serde(rename_all = "camelCase")]
103+pub struct DescribeServerResponse {
104+ #[serde(skip_serializing_if = "Option::is_none")]
105+ invite_code_required: Option<bool>,
106+ #[serde(skip_serializing_if = "Option::is_none")]
107+ phone_verification_required: Option<bool>,
108+ #[serde(skip_serializing_if = "Option::is_none")]
109+ available_user_domains: Option<Vec<String>>,
110+ #[serde(skip_serializing_if = "Option::is_none")]
111+ links: Option<DescribeServerLinks>,
112+ #[serde(skip_serializing_if = "Option::is_none")]
113+ contact: Option<DescribeServerContact>,
114+ #[serde(skip_serializing_if = "Option::is_none")]
115+ did: Option<String>,
116+}
117+118pub async fn create_session(
119 State(state): State<AppState>,
120 headers: HeaderMap,
···144 //No 2FA or already passed
145 let uri = format!(
146 "{}{}",
147+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.createSession"
148 );
149150 let mut req = axum::http::Request::post(uri);
···284 // Updating the actual email address by sending it on to the PDS
285 let uri = format!(
286 "{}{}",
287+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.updateEmail"
288 );
289 let mut req = axum::http::Request::post(uri);
290 if let Some(req_headers) = req.headers_mut() {
···337 }
338}
339340+pub async fn describe_server(
341+ State(state): State<AppState>,
342+ req: Request,
343+) -> Result<Response<Body>, StatusCode> {
344+ match proxy_get_json::<DescribeServerResponse>(
345+ &state,
346+ req,
347+ "/xrpc/com.atproto.server.describeServer",
348+ )
349+ .await?
350+ {
351+ ProxiedResult::Parsed {
352+ value: mut server_info,
353+ ..
354+ } => {
355+ //This signifies the server is configured for captcha verification
356+ server_info.phone_verification_required = Some(state.app_config.use_captcha);
357+ Ok(Json(server_info).into_response())
358+ }
359+ ProxiedResult::Passthrough(resp) => Ok(resp),
360+ }
361+}
362+363+/// Verify a gate code matches the handle and is not expired
364+async fn verify_gate_code(
365+ state: &AppState,
366+ code: &str,
367+ handle: &str,
368+) -> Result<bool, anyhow::Error> {
369+ // First, decrypt and verify the JWE token
370+ let payload = match verify_gate_token(code, &state.app_config.gate_jwe_key) {
371+ Ok(p) => p,
372+ Err(e) => {
373+ log::warn!("Failed to decrypt gate token: {}", e);
374+ return Ok(false);
375+ }
376+ };
377+378+ // Verify the handle matches
379+ if payload.handle != handle {
380+ log::warn!(
381+ "Gate code handle mismatch: expected {}, got {}",
382+ handle,
383+ payload.handle
384+ );
385+ return Ok(false);
386+ }
387+388+ let created_at = chrono::DateTime::parse_from_rfc3339(&payload.created_at)
389+ .map_err(|e| anyhow::anyhow!("Failed to parse created_at from token: {}", e))?
390+ .with_timezone(&Utc);
391+392+ let now = Utc::now();
393+ let age = now - created_at;
394+395+ // Check if the token is expired (5 minutes)
396+ if age > Duration::minutes(5) {
397+ log::warn!("Gate code expired for handle {}", handle);
398+ return Ok(false);
399+ }
400+401+ // Verify the token exists in the database (to prevent reuse)
402+ let row: Option<(String,)> =
403+ sqlx::query_as("SELECT code FROM gate_codes WHERE code = ? and handle = ? LIMIT 1")
404+ .bind(code)
405+ .bind(handle)
406+ .fetch_optional(&state.pds_gatekeeper_pool)
407+ .await?;
408+409+ if row.is_none() {
410+ log::warn!("Gate code not found in database or already used");
411+ return Ok(false);
412+ }
413+414+ // Token is valid, delete it so it can't be reused
415+ //TODO probably also delete expired codes? Will need to do that at some point probably altho the where is on code and handle
416+417+ sqlx::query("DELETE FROM gate_codes WHERE code = ?")
418+ .bind(code)
419+ .execute(&state.pds_gatekeeper_pool)
420+ .await?;
421+422+ Ok(true)
423+}
424+425pub async fn create_account(
426 State(state): State<AppState>,
427+ req: Request,
428) -> Result<Response<Body>, StatusCode> {
429+ let headers = req.headers().clone();
430+ let body_bytes = to_bytes(req.into_body(), usize::MAX)
431+ .await
432+ .map_err(|_| StatusCode::BAD_REQUEST)?;
433+434+ // Parse the body to check for verification code
435+ let account_request: CreateAccountRequest =
436+ serde_json::from_slice(&body_bytes).map_err(|e| {
437+ log::error!("Failed to parse create account request: {}", e);
438+ StatusCode::BAD_REQUEST
439+ })?;
440+441+ // Check for service auth (migrations) if configured
442+ if state.app_config.allow_only_migrations {
443+ // Expect Authorization: Bearer <jwt>
444+ let auth_header = headers
445+ .get(header::AUTHORIZATION)
446+ .and_then(|v| v.to_str().ok())
447+ .map(str::to_string);
448+449+ let Some(value) = auth_header else {
450+ log::error!("No Authorization header found in the request");
451+ return json_error_response(
452+ StatusCode::UNAUTHORIZED,
453+ "InvalidAuth",
454+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
455+ );
456+ };
457+458+ // Ensure Bearer prefix
459+ let token = value.strip_prefix("Bearer ").unwrap_or("").trim();
460+ if token.is_empty() {
461+ log::error!("No Service Auth token found in the Authorization header");
462+ return json_error_response(
463+ StatusCode::UNAUTHORIZED,
464+ "InvalidAuth",
465+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
466+ );
467+ }
468+469+ // Ensure a non-empty DID was provided when migrations are enabled
470+ let requested_did_str = match account_request.did.as_deref() {
471+ Some(s) if !s.trim().is_empty() => s,
472+ _ => {
473+ return json_error_response(
474+ StatusCode::BAD_REQUEST,
475+ "InvalidRequest",
476+ "The 'did' field is required when migrations are enforced.",
477+ );
478+ }
479+ };
480+481+ // Parse the DID into the expected type for verification
482+ let requested_did: JacquardDid<'static> = match requested_did_str.parse() {
483+ Ok(d) => d,
484+ Err(e) => {
485+ log::error!(
486+ "Invalid DID format provided in createAccount: {} | error: {}",
487+ requested_did_str,
488+ e
489+ );
490+ return json_error_response(
491+ StatusCode::BAD_REQUEST,
492+ "InvalidRequest",
493+ "The 'did' field is not a valid DID.",
494+ );
495+ }
496+ };
497498+ let nsid = "com.atproto.server.createAccount".parse().unwrap();
499+ match verify_service_auth(
500+ token,
501+ &nsid,
502+ state.resolver.clone(),
503+ &state.app_config.pds_service_did,
504+ &requested_did,
505+ )
506+ .await
507+ {
508+ //Just do nothing if it passes so it continues.
509+ Ok(_) => {}
510+ Err(err) => match err {
511+ VerifyServiceAuthError::AuthFailed => {
512+ return json_error_response(
513+ StatusCode::UNAUTHORIZED,
514+ "InvalidAuth",
515+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
516+ );
517+ }
518+ VerifyServiceAuthError::Error(err) => {
519+ log::error!("Error verifying service auth token: {err}");
520+ return json_error_response(
521+ StatusCode::BAD_REQUEST,
522+ "InvalidRequest",
523+ "There has been an error, please contact your PDS administrator for help and for them to review the server logs.",
524+ );
525+ }
526+ },
527+ }
528+ }
529+530+ // Check for captcha verification if configured
531+ if state.app_config.use_captcha {
532+ if let Some(ref verification_code) = account_request.verification_code {
533+ match verify_gate_code(&state, verification_code, &account_request.handle).await {
534+ //TODO has a few errors to support
535+536+ //expired token
537+ // {
538+ // "error": "ExpiredToken",
539+ // "message": "Token has expired"
540+ // }
541+542+ //TODO ALSO add rate limits on the /gate endpoints so they can't be abused
543+ Ok(true) => {
544+ log::info!("Gate code verified for handle: {}", account_request.handle);
545+ }
546+ Ok(false) => {
547+ log::warn!(
548+ "Invalid or expired gate code for handle: {}",
549+ account_request.handle
550+ );
551+ return json_error_response(
552+ StatusCode::BAD_REQUEST,
553+ "InvalidToken",
554+ "Token could not be verified",
555+ );
556+ }
557+ Err(e) => {
558+ log::error!("Error verifying gate code: {}", e);
559+ return json_error_response(
560+ StatusCode::INTERNAL_SERVER_ERROR,
561+ "InvalidToken",
562+ "Token could not be verified",
563+ );
564+ }
565+ }
566+ } else {
567+ // No verification code provided but captcha is required
568+ log::warn!(
569+ "No verification code provided for account creation: {}",
570+ account_request.handle
571+ );
572+ return json_error_response(
573+ StatusCode::BAD_REQUEST,
574+ "InvalidRequest",
575+ "Verification is now required on this server.",
576+ );
577+ }
578+ }
579+580+ // Rebuild the request with the same body and headers
581 let uri = format!(
582 "{}{}",
583+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.createAccount"
584 );
585586+ let mut new_req = axum::http::Request::post(&uri);
587+ if let Some(req_headers) = new_req.headers_mut() {
588+ *req_headers = headers;
589+ }
590+591+ let new_req = new_req
592+ .body(Body::from(body_bytes))
593+ .map_err(|_| StatusCode::BAD_REQUEST)?;
594595 let proxied = state
596 .reverse_proxy_client
597+ .request(new_req)
598 .await
599 .map_err(|_| StatusCode::BAD_REQUEST)?
600 .into_response();