···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
000000000000000000000001920-Future feature?
00002122# Setup
23···49 - pds
50```
5152-For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml).
05354```yml
55gatekeeper:
56- container_name: gatekeeper
57- image: 'fatfingers23/pds_gatekeeper:latest'
58- restart: unless-stopped
59- volumes:
60- - '/pds:/pds'
61- environment:
62- - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
63- - 'PDS_BASE_URL=http://pds:3000'
64- - GATEKEEPER_HOST=0.0.0.0
65- depends_on:
66- - pds
67- healthcheck:
68- test:
69- - CMD
70- - timeout
71- - '1'
72- - bash
73- - '-c'
74- - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
75- interval: 10s
76- timeout: 5s
77- retries: 3
78- start_period: 10s
79- labels:
80- - traefik.enable=true
81- - '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`))'
82- - traefik.http.routers.pds-gatekeeper.entrypoints=https
83- - traefik.http.routers.pds-gatekeeper.tls=true
84- - traefik.http.routers.pds-gatekeeper.priority=100
85- - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
86- - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
87- - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
88- - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
89- - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
90- - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
91- - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
92- - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
93- - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
94```
9596## Caddy setup
···99in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
100This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
101102-```caddyfile
103 @gatekeeper {
104- path /xrpc/com.atproto.server.getSession
105- path /xrpc/com.atproto.server.updateEmail
106- path /xrpc/com.atproto.server.createSession
107- path /xrpc/com.atproto.server.createAccount
108- path /@atproto/oauth-provider/~api/sign-in
00109 }
110111 handle @gatekeeper {
112- reverse_proxy http://localhost:8080
113- }
114115- reverse_proxy http://localhost:3000
116```
117118If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
119`localhost:8081` (or w/e port you want).
120121-```caddyfile
122http://*.localhost:8082, http://localhost:8082 {
123- @gatekeeper {
124- path /xrpc/com.atproto.server.getSession
125- path /xrpc/com.atproto.server.updateEmail
126- path /xrpc/com.atproto.server.createSession
127- path /xrpc/com.atproto.server.createAccount
128- path /@atproto/oauth-provider/~api/sign-in
129- }
00130131- handle @gatekeeper {
132- reverse_proxy http://localhost:8080 {
133- #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper
134- header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
135- }
136- }
137-138- reverse_proxy http://localhost:3000
139}
140141```
···168169`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
170the 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
171-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
19+20+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
4243+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···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).
8182```yml
83gatekeeper:
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```
123124## Caddy setup
···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+ }
162163+ 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
0170}
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+-- 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::TokenCheckError::InvalidToken;
3use anyhow::anyhow;
4-use axum::body::{Body, to_bytes};
5-use axum::extract::Request;
6-use axum::http::header::CONTENT_TYPE;
7-use axum::http::{HeaderMap, StatusCode, Uri};
8-use axum::response::{IntoResponse, Response};
009use axum_template::TemplateEngine;
10use chrono::Utc;
11-use lettre::message::{MultiPart, SinglePart, header};
12-use lettre::{AsyncTransport, Message};
0000000013use rand::Rng;
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use sqlx::SqlitePool;
18-use std::env;
19use tracing::{error, log};
2021///Used to generate the email 2fa code
···40where
41 T: DeserializeOwned,
42{
43- let uri = format!("{}{}", state.pds_base_url, path);
44 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
4546 let result = state
···333 let email_body = state
334 .template_engine
335 .render("two_factor_code.hbs", email_data)?;
336- let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT")
337- .unwrap_or("Sign in to Bluesky".to_string());
338339 let email_message = Message::builder()
340 //TODO prob get the proper type in the state
341- .from(state.mailer_from.parse()?)
342 .to(email.parse()?)
343- .subject(email_subject)
344 .multipart(
345 MultiPart::alternative() // This is composed of two parts.
346 .singlepart(
···523524 format!("{masked_local}@{masked_domain}")
525}
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1use crate::AppState;
2use crate::helpers::TokenCheckError::InvalidToken;
3use anyhow::anyhow;
4+use axum::{
5+ body::{Body, to_bytes},
6+ extract::Request,
7+ http::header::CONTENT_TYPE,
8+ http::{HeaderMap, StatusCode, Uri},
9+ response::{IntoResponse, Response},
10+};
11use axum_template::TemplateEngine;
12use chrono::Utc;
13+use jacquard_common::{
14+ service_auth, service_auth::PublicKey, types::did::Did, types::did_doc::VerificationMethod,
15+ types::nsid::Nsid,
16+};
17+use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
18+use josekit::jwe::alg::direct::DirectJweAlgorithm;
19+use lettre::{
20+ AsyncTransport, Message,
21+ message::{MultiPart, SinglePart, header},
22+};
23use rand::Rng;
24use serde::de::DeserializeOwned;
25use serde_json::{Map, Value};
26use sha2::{Digest, Sha256};
27use sqlx::SqlitePool;
28+use std::sync::Arc;
29use tracing::{error, log};
3031///Used to generate the email 2fa code
···50where
51 T: DeserializeOwned,
52{
53+ let uri = format!("{}{}", state.app_config.pds_base_url, path);
54 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
5556 let result = state
···343 let email_body = state
344 .template_engine
345 .render("two_factor_code.hbs", email_data)?;
00346347 let email_message = Message::builder()
348 //TODO prob get the proper type in the state
349+ .from(state.app_config.mailer_from.parse()?)
350 .to(email.parse()?)
351+ .subject(&state.app_config.email_subject)
352 .multipart(
353 MultiPart::alternative() // This is composed of two parts.
354 .singlepart(
···531532 format!("{masked_local}@{masked_domain}")
533}
534+535+pub enum VerifyServiceAuthError {
536+ AuthFailed,
537+ Error(anyhow::Error),
538+}
539+540+/// Verifies the service auth token that is appended to an XRPC proxy request
541+pub async fn verify_service_auth(
542+ jwt: &str,
543+ lxm: &Nsid<'static>,
544+ public_resolver: Arc<PublicResolver>,
545+ service_did: &Did<'static>,
546+ //The did of the user wanting to create an account
547+ requested_did: &Did<'static>,
548+) -> Result<(), VerifyServiceAuthError> {
549+ let parsed =
550+ service_auth::parse_jwt(jwt).map_err(|e| VerifyServiceAuthError::Error(e.into()))?;
551+552+ let claims = parsed.claims();
553+554+ let did_doc = public_resolver
555+ .resolve_did_doc(&requested_did)
556+ .await
557+ .map_err(|err| {
558+ log::error!("Error resolving the service auth for: {}", claims.iss);
559+ return VerifyServiceAuthError::Error(err.into());
560+ })?;
561+562+ // Parse the DID document response to get verification methods
563+ let doc = did_doc.parse().map_err(|err| {
564+ log::error!("Error parsing the service auth did doc: {}", claims.iss);
565+ VerifyServiceAuthError::Error(anyhow::anyhow!(err))
566+ })?;
567+568+ let verification_methods = doc.verification_method.as_deref().ok_or_else(|| {
569+ VerifyServiceAuthError::Error(anyhow::anyhow!(
570+ "No verification methods in did doc: {}",
571+ &claims.iss
572+ ))
573+ })?;
574+575+ let signing_key = extract_signing_key(verification_methods).ok_or_else(|| {
576+ VerifyServiceAuthError::Error(anyhow::anyhow!(
577+ "No signing key found in did doc: {}",
578+ &claims.iss
579+ ))
580+ })?;
581+582+ service_auth::verify_signature(&parsed, &signing_key).map_err(|err| {
583+ log::error!("Error verifying service auth signature: {}", err);
584+ VerifyServiceAuthError::AuthFailed
585+ })?;
586+587+ // Now validate claims (audience, expiration, etc.)
588+ claims.validate(service_did).map_err(|e| {
589+ log::error!("Error validating service auth claims: {}", e);
590+ VerifyServiceAuthError::AuthFailed
591+ })?;
592+593+ if claims.aud != *service_did {
594+ log::error!("Invalid audience (did:web): {}", claims.aud);
595+ return Err(VerifyServiceAuthError::AuthFailed);
596+ }
597+598+ let lxm_from_claims = claims.lxm.as_ref().ok_or_else(|| {
599+ VerifyServiceAuthError::Error(anyhow::anyhow!("No lxm claim in service auth JWT"))
600+ })?;
601+602+ if lxm_from_claims != lxm {
603+ return Err(VerifyServiceAuthError::Error(anyhow::anyhow!(
604+ "Invalid XRPC endpoint requested"
605+ )));
606+ }
607+ Ok(())
608+}
609+610+/// Ripped from Jacquard
611+///
612+/// Extract the signing key from a DID document's verification methods.
613+///
614+/// This looks for a key with type "atproto" or the first available key
615+/// if no atproto-specific key is found.
616+fn extract_signing_key(methods: &[VerificationMethod]) -> Option<PublicKey> {
617+ // First try to find an atproto-specific key
618+ let atproto_method = methods
619+ .iter()
620+ .find(|m| m.r#type.as_ref() == "Multikey" || m.r#type.as_ref() == "atproto");
621+622+ let method = atproto_method.or_else(|| methods.first())?;
623+624+ // Parse the multikey
625+ let public_key_multibase = method.public_key_multibase.as_ref()?;
626+627+ // Decode multibase
628+ let (_, key_bytes) = multibase::decode(public_key_multibase.as_ref()).ok()?;
629+630+ // First two bytes are the multicodec prefix
631+ if key_bytes.len() < 2 {
632+ return None;
633+ }
634+635+ let codec = &key_bytes[..2];
636+ let key_material = &key_bytes[2..];
637+638+ match codec {
639+ // p256-pub (0x1200)
640+ [0x80, 0x24] => PublicKey::from_p256_bytes(key_material).ok(),
641+ // secp256k1-pub (0xe7)
642+ [0xe7, 0x01] => PublicKey::from_k256_bytes(key_material).ok(),
643+ _ => None,
644+ }
645+}
646+647+/// Payload for gate JWE tokens
648+#[derive(serde::Serialize, serde::Deserialize, Debug)]
649+pub struct GateTokenPayload {
650+ pub handle: String,
651+ pub created_at: String,
652+}
653+654+/// Generate a secure JWE token for gate verification
655+pub fn generate_gate_token(handle: &str, encryption_key: &[u8]) -> Result<String, anyhow::Error> {
656+ use josekit::jwe::{JweHeader, alg::direct::DirectJweAlgorithm};
657+658+ let payload = GateTokenPayload {
659+ handle: handle.to_string(),
660+ created_at: Utc::now().to_rfc3339(),
661+ };
662+663+ let payload_json = serde_json::to_string(&payload)?;
664+665+ let mut header = JweHeader::new();
666+ header.set_token_type("JWT");
667+ header.set_content_encryption("A128CBC-HS256");
668+669+ let encrypter = DirectJweAlgorithm::Dir.encrypter_from_bytes(encryption_key)?;
670+671+ // Encrypt
672+ let jwe = josekit::jwe::serialize_compact(payload_json.as_bytes(), &header, &encrypter)?;
673+674+ Ok(jwe)
675+}
676+677+/// Verify and decrypt a gate JWE token, returning the payload if valid
678+pub fn verify_gate_token(
679+ token: &str,
680+ encryption_key: &[u8],
681+) -> Result<GateTokenPayload, anyhow::Error> {
682+ let decrypter = DirectJweAlgorithm::Dir.decrypter_from_bytes(encryption_key)?;
683+ let (payload_bytes, _header) = josekit::jwe::deserialize_compact(token, &decrypter)?;
684+ let payload: GateTokenPayload = serde_json::from_slice(&payload_bytes)?;
685+686+ Ok(payload)
687+}
+157-29
src/main.rs
···1#![warn(clippy::unwrap_used)]
02use crate::oauth_provider::sign_in;
3-use crate::xrpc::com_atproto_server::{create_account, create_session, get_session, update_email};
4-use axum::body::Body;
5-use axum::handler::Handler;
6-use axum::http::{Method, header};
7-use axum::middleware as ax_middleware;
8-use axum::routing::post;
9-use axum::{Router, routing::get};
0000010use axum_template::engine::Engine;
11use handlebars::Handlebars;
12-use hyper_util::client::legacy::connect::HttpConnector;
13-use hyper_util::rt::TokioExecutor;
014use lettre::{AsyncSmtpTransport, Tokio1Executor};
015use rust_embed::RustEmbed;
16use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
17use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
18use std::path::Path;
019use std::time::Duration;
20use std::{env, net::SocketAddr};
21-use tower_governor::GovernorLayer;
22-use tower_governor::governor::GovernorConfigBuilder;
23-use tower_governor::key_extractor::SmartIpKeyExtractor;
24-use tower_http::compression::CompressionLayer;
25-use tower_http::cors::{Any, CorsLayer};
0026use tracing::log;
27use tracing_subscriber::{EnvFilter, fmt, prelude::*};
28029pub mod helpers;
30mod middleware;
31mod oauth_provider;
···38#[include = "*.hbs"]
39struct EmailTemplates;
400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041#[derive(Clone)]
42pub struct AppState {
43 account_pool: SqlitePool,
44 pds_gatekeeper_pool: SqlitePool,
45 reverse_proxy_client: HyperUtilClient,
46- pds_base_url: String,
47 mailer: AsyncSmtpTransport<Tokio1Executor>,
48- mailer_from: String,
49 template_engine: Engine<Handlebars<'static>>,
0050}
5152async fn root_handler() -> impl axum::response::IntoResponse {
···137 //Emailer set up
138 let smtp_url =
139 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
140- let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS")
141- .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
142143 let mailer: AsyncSmtpTransport<Tokio1Executor> =
144 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
···155 let _ = hbs.register_embed_templates::<EmailTemplates>();
156 }
157158- let pds_base_url =
159- env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
00000000160161 let state = AppState {
162 account_pool,
163 pds_gatekeeper_pool,
164 reverse_proxy_client: client,
165- pds_base_url,
166 mailer,
167- mailer_from: sent_from,
168 template_engine: Engine::from(hbs),
00169 };
170171 // Rate limiting
172 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
173- let create_session_governor_conf = GovernorConfigBuilder::default()
174 .per_second(60)
175 .burst_size(5)
176 .key_extractor(SmartIpKeyExtractor)
···216 "failed to create governor config for create account. this should not happen and is a bug",
217 );
218219- let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
220 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
221 let create_account_governor_limiter = create_account_governor_conf.limiter().clone();
00222223 let interval = Duration::from_secs(60);
224 // a separate background task to clean up
225 std::thread::spawn(move || {
226 loop {
227 std::thread::sleep(interval);
228- create_session_governor_limiter.retain_recent();
229 sign_in_governor_limiter.retain_recent();
230 create_account_governor_limiter.retain_recent();
231 }
···236 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
237 .allow_headers(Any);
238239- let app = Router::new()
240 .route("/", get(root_handler))
241 .route("/xrpc/com.atproto.server.getSession", get(get_session))
242 .route(
0000243 "/xrpc/com.atproto.server.updateEmail",
244 post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)),
245 )
246 .route(
247 "/@atproto/oauth-provider/~api/sign-in",
248- post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
249 )
250 .route(
251 "/xrpc/com.atproto.server.createSession",
252- post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
253 )
254 .route(
255 "/xrpc/com.atproto.server.createAccount",
256 post(create_account).layer(GovernorLayer::new(create_account_governor_conf)),
257- )
000000000258 .layer(CompressionLayer::new())
259 .layer(cors)
260 .with_state(state);
···1#![warn(clippy::unwrap_used)]
2+use crate::gate::{get_gate, post_gate};
3use crate::oauth_provider::sign_in;
4+use crate::xrpc::com_atproto_server::{
5+ create_account, create_session, describe_server, get_session, update_email,
6+};
7+use axum::{
8+ Router,
9+ body::Body,
10+ handler::Handler,
11+ http::{Method, header},
12+ middleware as ax_middleware,
13+ routing::get,
14+ routing::post,
15+};
16use axum_template::engine::Engine;
17use handlebars::Handlebars;
18+use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
19+use jacquard_common::types::did::Did;
20+use jacquard_identity::{PublicResolver, resolver::PlcSource};
21use lettre::{AsyncSmtpTransport, Tokio1Executor};
22+use rand::Rng;
23use rust_embed::RustEmbed;
24use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
25use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
26use std::path::Path;
27+use std::sync::Arc;
28use std::time::Duration;
29use std::{env, net::SocketAddr};
30+use tower_governor::{
31+ GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
32+};
33+use tower_http::{
34+ compression::CompressionLayer,
35+ cors::{Any, CorsLayer},
36+};
37use tracing::log;
38use tracing_subscriber::{EnvFilter, fmt, prelude::*};
3940+mod gate;
41pub mod helpers;
42mod middleware;
43mod oauth_provider;
···50#[include = "*.hbs"]
51struct EmailTemplates;
5253+#[derive(RustEmbed)]
54+#[folder = "html_templates"]
55+#[include = "*.hbs"]
56+struct HtmlTemplates;
57+58+/// Mostly the env variables that are used in the app
59+#[derive(Clone, Debug)]
60+pub struct AppConfig {
61+ pds_base_url: String,
62+ mailer_from: String,
63+ email_subject: String,
64+ allow_only_migrations: bool,
65+ use_captcha: bool,
66+ //The url to redirect to after a successful captcha. Defaults to https://bsky.app, but you may have another social-app fork you rather your users use
67+ //that need to capture this redirect url for creating an account
68+ default_successful_redirect_url: String,
69+ pds_service_did: Did<'static>,
70+ gate_jwe_key: Vec<u8>,
71+ captcha_success_redirects: Vec<String>,
72+}
73+74+impl AppConfig {
75+ pub fn new() -> Self {
76+ let pds_base_url =
77+ env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
78+ let mailer_from = env::var("PDS_EMAIL_FROM_ADDRESS")
79+ .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
80+ //Hack not my favorite, but it does work
81+ let allow_only_migrations = env::var("GATEKEEPER_ALLOW_ONLY_MIGRATIONS")
82+ .map(|val| val.parse::<bool>().unwrap_or(false))
83+ .unwrap_or(false);
84+85+ let use_captcha = env::var("GATEKEEPER_CREATE_ACCOUNT_CAPTCHA")
86+ .map(|val| val.parse::<bool>().unwrap_or(false))
87+ .unwrap_or(false);
88+89+ // PDS_SERVICE_DID is the did:web if set, if not it's PDS_HOSTNAME
90+ let pds_service_did =
91+ env::var("PDS_SERVICE_DID").unwrap_or_else(|_| match env::var("PDS_HOSTNAME") {
92+ Ok(pds_hostname) => format!("did:web:{}", pds_hostname),
93+ Err(_) => {
94+ panic!("PDS_HOSTNAME or PDS_SERVICE_DID must be set in your pds.env file")
95+ }
96+ });
97+98+ let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT")
99+ .unwrap_or("Sign in to Bluesky".to_string());
100+101+ // Load or generate JWE encryption key (32 bytes for AES-256)
102+ let gate_jwe_key = env::var("GATEKEEPER_JWE_KEY")
103+ .ok()
104+ .and_then(|key_hex| hex::decode(key_hex).ok())
105+ .unwrap_or_else(|| {
106+ // Generate a random 32-byte key if not provided
107+ let key: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
108+ log::warn!("WARNING: No GATEKEEPER_JWE_KEY found in the environment. Generated random key (hex): {}", hex::encode(&key));
109+ log::warn!("This is not strictly needed unless you scale PDS Gatekeeper. Will not also be able to verify tokens between reboots, but they are short lived (5mins).");
110+ key
111+ });
112+113+ if gate_jwe_key.len() != 32 {
114+ panic!(
115+ "GATEKEEPER_JWE_KEY must be 32 bytes (64 hex characters) for AES-256 encryption"
116+ );
117+ }
118+119+ let captcha_success_redirects = match env::var("GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS") {
120+ Ok(from_env) => from_env.split(",").map(|s| s.trim().to_string()).collect(),
121+ Err(_) => {
122+ vec![
123+ String::from("https://bsky.app"),
124+ String::from("https://pdsmoover.com"),
125+ String::from("https://blacksky.community"),
126+ String::from("https://tektite.cc"),
127+ ]
128+ }
129+ };
130+131+ AppConfig {
132+ pds_base_url,
133+ mailer_from,
134+ email_subject,
135+ allow_only_migrations,
136+ use_captcha,
137+ default_successful_redirect_url: env::var("GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT")
138+ .unwrap_or("https://bsky.app".to_string()),
139+ pds_service_did: pds_service_did
140+ .parse()
141+ .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"),
142+ gate_jwe_key,
143+ captcha_success_redirects,
144+ }
145+ }
146+}
147+148#[derive(Clone)]
149pub struct AppState {
150 account_pool: SqlitePool,
151 pds_gatekeeper_pool: SqlitePool,
152 reverse_proxy_client: HyperUtilClient,
0153 mailer: AsyncSmtpTransport<Tokio1Executor>,
0154 template_engine: Engine<Handlebars<'static>>,
155+ resolver: Arc<PublicResolver>,
156+ app_config: AppConfig,
157}
158159async fn root_handler() -> impl axum::response::IntoResponse {
···244 //Emailer set up
245 let smtp_url =
246 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
00247248 let mailer: AsyncSmtpTransport<Tokio1Executor> =
249 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
···260 let _ = hbs.register_embed_templates::<EmailTemplates>();
261 }
262263+ let _ = hbs.register_embed_templates::<HtmlTemplates>();
264+265+ //Reads the PLC source from the pds env's or defaults to ol faithful
266+ let plc_source_url =
267+ env::var("PDS_DID_PLC_URL").unwrap_or_else(|_| "https://plc.directory".to_string());
268+ let plc_source = PlcSource::PlcDirectory {
269+ base: plc_source_url.parse().unwrap(),
270+ };
271+ let mut resolver = PublicResolver::default();
272+ resolver = resolver.with_plc_source(plc_source.clone());
273274 let state = AppState {
275 account_pool,
276 pds_gatekeeper_pool,
277 reverse_proxy_client: client,
0278 mailer,
0279 template_engine: Engine::from(hbs),
280+ resolver: Arc::new(resolver),
281+ app_config: AppConfig::new(),
282 };
283284 // Rate limiting
285 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
286+ let captcha_governor_conf = GovernorConfigBuilder::default()
287 .per_second(60)
288 .burst_size(5)
289 .key_extractor(SmartIpKeyExtractor)
···329 "failed to create governor config for create account. this should not happen and is a bug",
330 );
331332+ let captcha_governor_limiter = captcha_governor_conf.limiter().clone();
333 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
334 let create_account_governor_limiter = create_account_governor_conf.limiter().clone();
335+336+ let sign_in_governor_layer = GovernorLayer::new(sign_in_governor_conf);
337338 let interval = Duration::from_secs(60);
339 // a separate background task to clean up
340 std::thread::spawn(move || {
341 loop {
342 std::thread::sleep(interval);
343+ captcha_governor_limiter.retain_recent();
344 sign_in_governor_limiter.retain_recent();
345 create_account_governor_limiter.retain_recent();
346 }
···351 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
352 .allow_headers(Any);
353354+ let mut app = Router::new()
355 .route("/", get(root_handler))
356 .route("/xrpc/com.atproto.server.getSession", get(get_session))
357 .route(
358+ "/xrpc/com.atproto.server.describeServer",
359+ get(describe_server),
360+ )
361+ .route(
362 "/xrpc/com.atproto.server.updateEmail",
363 post(update_email).layer(ax_middleware::from_fn(middleware::extract_did)),
364 )
365 .route(
366 "/@atproto/oauth-provider/~api/sign-in",
367+ post(sign_in).layer(sign_in_governor_layer.clone()),
368 )
369 .route(
370 "/xrpc/com.atproto.server.createSession",
371+ post(create_session.layer(sign_in_governor_layer)),
372 )
373 .route(
374 "/xrpc/com.atproto.server.createAccount",
375 post(create_account).layer(GovernorLayer::new(create_account_governor_conf)),
376+ );
377+378+ if state.app_config.use_captcha {
379+ app = app.route(
380+ "/gate/signup",
381+ get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))),
382+ );
383+ }
384+385+ let app = app
386 .layer(CompressionLayer::new())
387 .layer(cors)
388 .with_state(state);
+1-1
src/oauth_provider.rs
···57 //No 2FA or already passed
58 let uri = format!(
59 "{}{}",
60- state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
61 );
6263 let mut req = axum::http::Request::post(uri);
···57 //No 2FA or already passed
58 let uri = format!(
59 "{}{}",
60+ state.app_config.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
61 );
6263 let mut req = axum::http::Request::post(uri);
+306-11
src/xrpc/com_atproto_server.rs
···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();