···11+MIT License
22+33+Copyright (c) 2025 Bailey Townsend
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+171-12
README.md
···1515- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
1616- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
17171818-## Captcha on Create Account
1818+## Captcha on account creation
19192020-Future feature?
2020+Require a `verificationCode` set on the `createAccount` request. This is gotten from completing a captcha challenge
2121+hosted on the
2222+PDS mimicking what the Bluesky Entryway does. Migration tools will need to support this, but social-apps will support
2323+and redirect to `GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT`. This is how the clients know to get the code to prove a captcha
2424+was successful.
2525+2626+- Requires `GATEKEEPER_CREATE_ACCOUNT_CAPTCHA` to be set to true.
2727+- Requires `PDS_HCAPTCHA_SITE_KEY` and `PDS_HCAPTCHA_SECRET_KEY` to be set. Can sign up at https://www.hcaptcha.com/
2828+- Requires proxying `/xrpc/com.atproto.server.describeServer`, `/xrpc/com.atproto.server.createAccount` and `/gate/*` to
2929+ PDS
3030+ Gatekeeper
3131+- Optional `GATEKEEPER_JWE_KEY` key to encrypt the captcha verification code. Defaults to a random 32 byte key. Not
3232+ strictly needed unless you're scaling
3333+- Optional`GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT` default redirect on captcha success. Defaults to `https://bsky.app`.
3434+- Optional `GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS` allowed redirect urls for captcha success. You want these to match the
3535+ url showing the captcha. Defaults are:
3636+ - https://bsky.app
3737+ - https://pdsmoover.com
3838+ - https://blacksky.community
3939+ - https://tektite.cc
4040+4141+## Block account creation unless it's a migration
4242+4343+You can set `GATEKEEPER_ALLOW_ONLY_MIGRATIONS` to block createAccount unless it's via a migration. This does not require
4444+a change for migration tools, but social-apps create a new account will no longer work and to create a brand new account
4545+users will need to do this via the Oauth account create screen on the PDS. We recommend setting `PDS_HCAPTCHA_SITE_KEY`
4646+and `PDS_HCAPTCHA_SECRET_KEY` so the OAuth screen is protected by a captcha if you use this with invite codes turned
4747+off.
21482249# Setup
23502424-We are getting close! Testing now
5151+PDS Gatekeeper has 2 parts to its setup, docker compose file and a reverse proxy (Caddy in this case). I will be
5252+assuming you setup the PDS following the directions
5353+found [here](https://atproto.com/guides/self-hosting), but if yours is different, or you have questions, feel free to
5454+let
5555+me know, and we can figure it out.
5656+5757+## Docker compose
5858+5959+The pds gatekeeper container can be found on docker hub under the name `fatfingers23/pds_gatekeeper`. The container does
6060+need access to the `/pds` root folder to access the same db's as your PDS. The part you need to add would look a bit
6161+like below. You can find a full example of what I use for my pds at [./examples/compose.yml](./examples/compose.yml).
6262+This is usually found at `/pds/compose.yaml`on your PDS>
6363+6464+```yml
6565+ gatekeeper:
6666+ container_name: gatekeeper
6767+ image: fatfingers23/pds_gatekeeper:latest
6868+ network_mode: host
6969+ restart: unless-stopped
7070+ #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
7171+ volumes:
7272+ - type: bind
7373+ source: /pds
7474+ target: /pds
7575+ depends_on:
7676+ - pds
7777+```
7878+7979+For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up
8080+correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml).
8181+8282+```yml
8383+gatekeeper:
8484+ container_name: gatekeeper
8585+ image: 'fatfingers23/pds_gatekeeper:latest'
8686+ restart: unless-stopped
8787+ volumes:
8888+ - '/pds:/pds'
8989+ environment:
9090+ - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
9191+ - 'PDS_BASE_URL=http://pds:3000'
9292+ - GATEKEEPER_HOST=0.0.0.0
9393+ depends_on:
9494+ - pds
9595+ healthcheck:
9696+ test:
9797+ - CMD
9898+ - timeout
9999+ - '1'
100100+ - bash
101101+ - '-c'
102102+ - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
103103+ interval: 10s
104104+ timeout: 5s
105105+ retries: 3
106106+ start_period: 10s
107107+ labels:
108108+ - traefik.enable=true
109109+ - '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`))'
110110+ - traefik.http.routers.pds-gatekeeper.entrypoints=https
111111+ - traefik.http.routers.pds-gatekeeper.tls=true
112112+ - traefik.http.routers.pds-gatekeeper.priority=100
113113+ - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
114114+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
115115+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
116116+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
117117+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
118118+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
119119+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
120120+ - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
121121+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
122122+```
251232626-Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
2727-But I want to run it locally on my own PDS first to test run it a bit.
124124+## Caddy setup
281252929-Example Caddyfile (mostly so I don't lose it for now. Will have a better one in the future)
126126+For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
127127+in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
128128+This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
301293131-```caddyfile
3232-http://localhost {
130130+```
131131+ @gatekeeper {
132132+ path /xrpc/com.atproto.server.getSession
133133+ path /xrpc/com.atproto.server.describeServer
134134+ path /xrpc/com.atproto.server.updateEmail
135135+ path /xrpc/com.atproto.server.createSession
136136+ path /xrpc/com.atproto.server.createAccount
137137+ path /@atproto/oauth-provider/~api/sign-in
138138+ path /gate/*
139139+ }
140140+141141+ handle @gatekeeper {
142142+ reverse_proxy http://localhost:8080
143143+ }
33144145145+ reverse_proxy http://localhost:3000
146146+```
147147+148148+If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
149149+`localhost:8081` (or w/e port you want).
150150+151151+```
152152+http://*.localhost:8082, http://localhost:8082 {
34153 @gatekeeper {
35154 path /xrpc/com.atproto.server.getSession
155155+ path /xrpc/com.atproto.server.describeServer
36156 path /xrpc/com.atproto.server.updateEmail
37157 path /xrpc/com.atproto.server.createSession
158158+ path /xrpc/com.atproto.server.createAccount
38159 path /@atproto/oauth-provider/~api/sign-in
160160+ path /gate/*
39161 }
4016241163 handle @gatekeeper {
4242- reverse_proxy http://localhost:8080
164164+ #This is the address for PDS gatekeeper, default is 8080
165165+ reverse_proxy http://localhost:8080
166166+ #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper
167167+ header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
43168 }
169169+ reverse_proxy http://localhost:3000
170170+}
171171+172172+```
173173+174174+# Environment variables and bonuses
175175+176176+Every environment variable can be set in the `pds.env` and shared between PDS and gatekeeper and the PDS, with the
177177+exception of `PDS_ENV_LOCATION`. This can be set to load the pds.env, by default it checks `/pds/pds.env` and is
178178+recommended to mount the `/pds` folder on the server to `/pds` in the pds gatekeeper container.
179179+180180+`PDS_DATA_DIRECTORY` - Root directory of the PDS. Same as the one found in `pds.env` this is how pds gatekeeper knows
181181+knows the rest of the environment variables.
182182+183183+`GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY` - The folder for templates of the emails PDS gatekeeper sends. You can find them
184184+in [./email_templates](./email_templates). You are free to edit them as you please and set this variable to a location
185185+in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the
186186+same.
441874545- reverse_proxy /* http://localhost:3000
4646-}
188188+`GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT` - Subject of the email sent to the user when they turn on 2FA. Defaults to
189189+`Sign in to Bluesky`
190190+191191+`PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default
192192+193193+`GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1`
194194+195195+`GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080`
471964848-```197197+`GATEKEEPER_CREATE_ACCOUNT_PER_SECOND` - Sets how often it takes a count off the limiter. example if you hit the rate
198198+limit of 5 and set to 60, then in 60 seconds you will be able to make one more. Or in 5 minutes be able to make 5 more.
199199+200200+`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
201201+the 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
202202+off.
203203+204204+`GATEKEEPER_ALLOW_ONLY_MIGRATIONS` - Defaults false. If set to true, will only allow the
205205+`/xrpc/com.atproto.server.createAccount` endpoint to be used for migrations. Meaning it will check for the serviceAuth
206206+token and verify it is valid.
207207+
+30
examples/Caddyfile
···11+{
22+ email youremail@myemail.com
33+ on_demand_tls {
44+ ask http://localhost:3000/tls-check
55+ }
66+}
77+88+*.yourpds.com, yourpds.com {
99+ tls {
1010+ on_demand
1111+ }
1212+# You'll most likely just want from here to....
1313+ @gatekeeper {
1414+ path /xrpc/com.atproto.server.getSession
1515+ path /xrpc/com.atproto.server.describeServer
1616+ path /xrpc/com.atproto.server.updateEmail
1717+ path /xrpc/com.atproto.server.createSession
1818+ path /xrpc/com.atproto.server.createAccount
1919+ path /@atproto/oauth-provider/~api/sign-in
2020+ path /gate/*
2121+ }
2222+2323+ handle @gatekeeper {
2424+ #This is the address for PDS gatekeeper, default is 8080
2525+ reverse_proxy http://localhost:8080
2626+ }
2727+2828+ reverse_proxy http://localhost:3000
2929+#..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
3030+}
···11+-- Add migration script here
22+CREATE TABLE IF NOT EXISTS gate_codes
33+(
44+ code VARCHAR PRIMARY KEY,
55+ handle VARCHAR NOT NULL,
66+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
77+);
88+99+-- Index on created_at for efficient cleanup of expired codes
1010+CREATE INDEX IF NOT EXISTS idx_gate_codes_created_at ON gate_codes(created_at);
···11use crate::AppState;
22use crate::helpers::TokenCheckError::InvalidToken;
33use anyhow::anyhow;
44-use axum::body::{Body, to_bytes};
55-use axum::extract::Request;
66-use axum::http::header::CONTENT_TYPE;
77-use axum::http::{HeaderMap, StatusCode, Uri};
88-use axum::response::{IntoResponse, Response};
44+use axum::{
55+ body::{Body, to_bytes},
66+ extract::Request,
77+ http::header::CONTENT_TYPE,
88+ http::{HeaderMap, StatusCode, Uri},
99+ response::{IntoResponse, Response},
1010+};
911use axum_template::TemplateEngine;
1012use chrono::Utc;
1111-use lettre::message::{MultiPart, SinglePart, header};
1212-use lettre::{AsyncTransport, Message};
1313+use jacquard_common::{
1414+ service_auth, service_auth::PublicKey, types::did::Did, types::did_doc::VerificationMethod,
1515+ types::nsid::Nsid,
1616+};
1717+use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
1818+use josekit::jwe::alg::direct::DirectJweAlgorithm;
1919+use lettre::{
2020+ AsyncTransport, Message,
2121+ message::{MultiPart, SinglePart, header},
2222+};
1323use rand::Rng;
1424use serde::de::DeserializeOwned;
1525use serde_json::{Map, Value};
1626use sha2::{Digest, Sha256};
1727use sqlx::SqlitePool;
2828+use std::sync::Arc;
1829use tracing::{error, log};
19302031///Used to generate the email 2fa code
···3950where
4051 T: DeserializeOwned,
4152{
4242- let uri = format!("{}{}", state.pds_base_url, path);
5353+ let uri = format!("{}{}", state.app_config.pds_base_url, path);
4354 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
44554556 let result = state
···134145 full_code.push(UPPERCASE_BASE32_CHARS[idx] as char);
135146 }
136147137137- //The PDS implementation creates in lowercase, then converts to uppercase.
138138- //Just going a head and doing uppercase here.
139139- let slice_one = &full_code[0..5].to_ascii_uppercase();
140140- let slice_two = &full_code[5..10].to_ascii_uppercase();
148148+ let slice_one = &full_code[0..5];
149149+ let slice_two = &full_code[5..10];
141150 format!("{slice_one}-{slice_two}")
142151}
143152···337346338347 let email_message = Message::builder()
339348 //TODO prob get the proper type in the state
340340- .from(state.mailer_from.parse()?)
349349+ .from(state.app_config.mailer_from.parse()?)
341350 .to(email.parse()?)
342342- .subject("Sign in to Bluesky")
351351+ .subject(&state.app_config.email_subject)
343352 .multipart(
344353 MultiPart::alternative() // This is composed of two parts.
345354 .singlepart(
···522531523532 format!("{masked_local}@{masked_domain}")
524533}
534534+535535+pub enum VerifyServiceAuthError {
536536+ AuthFailed,
537537+ Error(anyhow::Error),
538538+}
539539+540540+/// Verifies the service auth token that is appended to an XRPC proxy request
541541+pub async fn verify_service_auth(
542542+ jwt: &str,
543543+ lxm: &Nsid<'static>,
544544+ public_resolver: Arc<PublicResolver>,
545545+ service_did: &Did<'static>,
546546+ //The did of the user wanting to create an account
547547+ requested_did: &Did<'static>,
548548+) -> Result<(), VerifyServiceAuthError> {
549549+ let parsed =
550550+ service_auth::parse_jwt(jwt).map_err(|e| VerifyServiceAuthError::Error(e.into()))?;
551551+552552+ let claims = parsed.claims();
553553+554554+ let did_doc = public_resolver
555555+ .resolve_did_doc(&requested_did)
556556+ .await
557557+ .map_err(|err| {
558558+ log::error!("Error resolving the service auth for: {}", claims.iss);
559559+ return VerifyServiceAuthError::Error(err.into());
560560+ })?;
561561+562562+ // Parse the DID document response to get verification methods
563563+ let doc = did_doc.parse().map_err(|err| {
564564+ log::error!("Error parsing the service auth did doc: {}", claims.iss);
565565+ VerifyServiceAuthError::Error(anyhow::anyhow!(err))
566566+ })?;
567567+568568+ let verification_methods = doc.verification_method.as_deref().ok_or_else(|| {
569569+ VerifyServiceAuthError::Error(anyhow::anyhow!(
570570+ "No verification methods in did doc: {}",
571571+ &claims.iss
572572+ ))
573573+ })?;
574574+575575+ let signing_key = extract_signing_key(verification_methods).ok_or_else(|| {
576576+ VerifyServiceAuthError::Error(anyhow::anyhow!(
577577+ "No signing key found in did doc: {}",
578578+ &claims.iss
579579+ ))
580580+ })?;
581581+582582+ service_auth::verify_signature(&parsed, &signing_key).map_err(|err| {
583583+ log::error!("Error verifying service auth signature: {}", err);
584584+ VerifyServiceAuthError::AuthFailed
585585+ })?;
586586+587587+ // Now validate claims (audience, expiration, etc.)
588588+ claims.validate(service_did).map_err(|e| {
589589+ log::error!("Error validating service auth claims: {}", e);
590590+ VerifyServiceAuthError::AuthFailed
591591+ })?;
592592+593593+ if claims.aud != *service_did {
594594+ log::error!("Invalid audience (did:web): {}", claims.aud);
595595+ return Err(VerifyServiceAuthError::AuthFailed);
596596+ }
597597+598598+ let lxm_from_claims = claims.lxm.as_ref().ok_or_else(|| {
599599+ VerifyServiceAuthError::Error(anyhow::anyhow!("No lxm claim in service auth JWT"))
600600+ })?;
601601+602602+ if lxm_from_claims != lxm {
603603+ return Err(VerifyServiceAuthError::Error(anyhow::anyhow!(
604604+ "Invalid XRPC endpoint requested"
605605+ )));
606606+ }
607607+ Ok(())
608608+}
609609+610610+/// Ripped from Jacquard
611611+///
612612+/// Extract the signing key from a DID document's verification methods.
613613+///
614614+/// This looks for a key with type "atproto" or the first available key
615615+/// if no atproto-specific key is found.
616616+fn extract_signing_key(methods: &[VerificationMethod]) -> Option<PublicKey> {
617617+ // First try to find an atproto-specific key
618618+ let atproto_method = methods
619619+ .iter()
620620+ .find(|m| m.r#type.as_ref() == "Multikey" || m.r#type.as_ref() == "atproto");
621621+622622+ let method = atproto_method.or_else(|| methods.first())?;
623623+624624+ // Parse the multikey
625625+ let public_key_multibase = method.public_key_multibase.as_ref()?;
626626+627627+ // Decode multibase
628628+ let (_, key_bytes) = multibase::decode(public_key_multibase.as_ref()).ok()?;
629629+630630+ // First two bytes are the multicodec prefix
631631+ if key_bytes.len() < 2 {
632632+ return None;
633633+ }
634634+635635+ let codec = &key_bytes[..2];
636636+ let key_material = &key_bytes[2..];
637637+638638+ match codec {
639639+ // p256-pub (0x1200)
640640+ [0x80, 0x24] => PublicKey::from_p256_bytes(key_material).ok(),
641641+ // secp256k1-pub (0xe7)
642642+ [0xe7, 0x01] => PublicKey::from_k256_bytes(key_material).ok(),
643643+ _ => None,
644644+ }
645645+}
646646+647647+/// Payload for gate JWE tokens
648648+#[derive(serde::Serialize, serde::Deserialize, Debug)]
649649+pub struct GateTokenPayload {
650650+ pub handle: String,
651651+ pub created_at: String,
652652+}
653653+654654+/// Generate a secure JWE token for gate verification
655655+pub fn generate_gate_token(handle: &str, encryption_key: &[u8]) -> Result<String, anyhow::Error> {
656656+ use josekit::jwe::{JweHeader, alg::direct::DirectJweAlgorithm};
657657+658658+ let payload = GateTokenPayload {
659659+ handle: handle.to_string(),
660660+ created_at: Utc::now().to_rfc3339(),
661661+ };
662662+663663+ let payload_json = serde_json::to_string(&payload)?;
664664+665665+ let mut header = JweHeader::new();
666666+ header.set_token_type("JWT");
667667+ header.set_content_encryption("A128CBC-HS256");
668668+669669+ let encrypter = DirectJweAlgorithm::Dir.encrypter_from_bytes(encryption_key)?;
670670+671671+ // Encrypt
672672+ let jwe = josekit::jwe::serialize_compact(payload_json.as_bytes(), &header, &encrypter)?;
673673+674674+ Ok(jwe)
675675+}
676676+677677+/// Verify and decrypt a gate JWE token, returning the payload if valid
678678+pub fn verify_gate_token(
679679+ token: &str,
680680+ encryption_key: &[u8],
681681+) -> Result<GateTokenPayload, anyhow::Error> {
682682+ let decrypter = DirectJweAlgorithm::Dir.decrypter_from_bytes(encryption_key)?;
683683+ let (payload_bytes, _header) = josekit::jwe::deserialize_compact(token, &decrypter)?;
684684+ let payload: GateTokenPayload = serde_json::from_slice(&payload_bytes)?;
685685+686686+ Ok(payload)
687687+}
+211-35
src/main.rs
···11#![warn(clippy::unwrap_used)]
22+use crate::gate::{get_gate, post_gate};
23use crate::oauth_provider::sign_in;
33-use crate::xrpc::com_atproto_server::{create_session, get_session, update_email};
44-use axum::body::Body;
55-use axum::handler::Handler;
66-use axum::http::{Method, header};
77-use axum::middleware as ax_middleware;
88-use axum::routing::post;
99-use axum::{Router, routing::get};
44+use crate::xrpc::com_atproto_server::{
55+ create_account, create_session, describe_server, get_session, update_email,
66+};
77+use axum::{
88+ Router,
99+ body::Body,
1010+ handler::Handler,
1111+ http::{Method, header},
1212+ middleware as ax_middleware,
1313+ routing::get,
1414+ routing::post,
1515+};
1016use axum_template::engine::Engine;
1117use handlebars::Handlebars;
1212-use hyper_util::client::legacy::connect::HttpConnector;
1313-use hyper_util::rt::TokioExecutor;
1818+use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
1919+use jacquard_common::types::did::Did;
2020+use jacquard_identity::{PublicResolver, resolver::PlcSource};
1421use lettre::{AsyncSmtpTransport, Tokio1Executor};
2222+use rand::Rng;
1523use rust_embed::RustEmbed;
1624use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
1725use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
1826use std::path::Path;
2727+use std::sync::Arc;
1928use std::time::Duration;
2029use std::{env, net::SocketAddr};
2121-use tower_governor::GovernorLayer;
2222-use tower_governor::governor::GovernorConfigBuilder;
2323-use tower_http::compression::CompressionLayer;
2424-use tower_http::cors::{Any, CorsLayer};
3030+use tower_governor::{
3131+ GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
3232+};
3333+use tower_http::{
3434+ compression::CompressionLayer,
3535+ cors::{Any, CorsLayer},
3636+};
2537use tracing::log;
2638use tracing_subscriber::{EnvFilter, fmt, prelude::*};
27394040+mod gate;
2841pub mod helpers;
2942mod middleware;
3043mod oauth_provider;
···3750#[include = "*.hbs"]
3851struct EmailTemplates;
39525353+#[derive(RustEmbed)]
5454+#[folder = "html_templates"]
5555+#[include = "*.hbs"]
5656+struct HtmlTemplates;
5757+5858+/// Mostly the env variables that are used in the app
5959+#[derive(Clone, Debug)]
6060+pub struct AppConfig {
6161+ pds_base_url: String,
6262+ mailer_from: String,
6363+ email_subject: String,
6464+ allow_only_migrations: bool,
6565+ use_captcha: bool,
6666+ //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
6767+ //that need to capture this redirect url for creating an account
6868+ default_successful_redirect_url: String,
6969+ pds_service_did: Did<'static>,
7070+ gate_jwe_key: Vec<u8>,
7171+ captcha_success_redirects: Vec<String>,
7272+}
7373+7474+impl AppConfig {
7575+ pub fn new() -> Self {
7676+ let pds_base_url =
7777+ env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
7878+ let mailer_from = env::var("PDS_EMAIL_FROM_ADDRESS")
7979+ .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
8080+ //Hack not my favorite, but it does work
8181+ let allow_only_migrations = env::var("GATEKEEPER_ALLOW_ONLY_MIGRATIONS")
8282+ .map(|val| val.parse::<bool>().unwrap_or(false))
8383+ .unwrap_or(false);
8484+8585+ let use_captcha = env::var("GATEKEEPER_CREATE_ACCOUNT_CAPTCHA")
8686+ .map(|val| val.parse::<bool>().unwrap_or(false))
8787+ .unwrap_or(false);
8888+8989+ // PDS_SERVICE_DID is the did:web if set, if not it's PDS_HOSTNAME
9090+ let pds_service_did =
9191+ env::var("PDS_SERVICE_DID").unwrap_or_else(|_| match env::var("PDS_HOSTNAME") {
9292+ Ok(pds_hostname) => format!("did:web:{}", pds_hostname),
9393+ Err(_) => {
9494+ panic!("PDS_HOSTNAME or PDS_SERVICE_DID must be set in your pds.env file")
9595+ }
9696+ });
9797+9898+ let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT")
9999+ .unwrap_or("Sign in to Bluesky".to_string());
100100+101101+ // Load or generate JWE encryption key (32 bytes for AES-256)
102102+ let gate_jwe_key = env::var("GATEKEEPER_JWE_KEY")
103103+ .ok()
104104+ .and_then(|key_hex| hex::decode(key_hex).ok())
105105+ .unwrap_or_else(|| {
106106+ // Generate a random 32-byte key if not provided
107107+ let key: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
108108+ log::warn!("WARNING: No GATEKEEPER_JWE_KEY found in the environment. Generated random key (hex): {}", hex::encode(&key));
109109+ 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).");
110110+ key
111111+ });
112112+113113+ if gate_jwe_key.len() != 32 {
114114+ panic!(
115115+ "GATEKEEPER_JWE_KEY must be 32 bytes (64 hex characters) for AES-256 encryption"
116116+ );
117117+ }
118118+119119+ let captcha_success_redirects = match env::var("GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS") {
120120+ Ok(from_env) => from_env.split(",").map(|s| s.trim().to_string()).collect(),
121121+ Err(_) => {
122122+ vec![
123123+ String::from("https://bsky.app"),
124124+ String::from("https://pdsmoover.com"),
125125+ String::from("https://blacksky.community"),
126126+ String::from("https://tektite.cc"),
127127+ ]
128128+ }
129129+ };
130130+131131+ AppConfig {
132132+ pds_base_url,
133133+ mailer_from,
134134+ email_subject,
135135+ allow_only_migrations,
136136+ use_captcha,
137137+ default_successful_redirect_url: env::var("GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT")
138138+ .unwrap_or("https://bsky.app".to_string()),
139139+ pds_service_did: pds_service_did
140140+ .parse()
141141+ .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"),
142142+ gate_jwe_key,
143143+ captcha_success_redirects,
144144+ }
145145+ }
146146+}
147147+40148#[derive(Clone)]
41149pub struct AppState {
42150 account_pool: SqlitePool,
43151 pds_gatekeeper_pool: SqlitePool,
44152 reverse_proxy_client: HyperUtilClient,
4545- pds_base_url: String,
46153 mailer: AsyncSmtpTransport<Tokio1Executor>,
4747- mailer_from: String,
48154 template_engine: Engine<Handlebars<'static>>,
155155+ resolver: Arc<PublicResolver>,
156156+ app_config: AppConfig,
49157}
5015851159async fn root_handler() -> impl axum::response::IntoResponse {
···88196#[tokio::main]
89197async fn main() -> Result<(), Box<dyn std::error::Error>> {
90198 setup_tracing();
9191- //TODO may need to change where this reads from? Like an env variable for it's location? Or arg?
9292- dotenvy::from_path(Path::new("./pds.env"))?;
9393- let pds_root = env::var("PDS_DATA_DIRECTORY")?;
199199+ let pds_env_location =
200200+ env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string());
201201+202202+ let result_of_finding_pds_env = dotenvy::from_path(Path::new(&pds_env_location));
203203+ if let Err(e) = result_of_finding_pds_env {
204204+ log::error!(
205205+ "Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}"
206206+ );
207207+ }
208208+209209+ let pds_root =
210210+ env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file");
94211 let account_db_url = format!("{pds_root}/account.sqlite");
9521296213 let account_options = SqliteConnectOptions::new()
···127244 //Emailer set up
128245 let smtp_url =
129246 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
130130- let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS")
131131- .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
247247+132248 let mailer: AsyncSmtpTransport<Tokio1Executor> =
133249 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
134250 //Email templates setup
···144260 let _ = hbs.register_embed_templates::<EmailTemplates>();
145261 }
146262147147- let pds_base_url =
148148- env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
263263+ let _ = hbs.register_embed_templates::<HtmlTemplates>();
264264+265265+ //Reads the PLC source from the pds env's or defaults to ol faithful
266266+ let plc_source_url =
267267+ env::var("PDS_DID_PLC_URL").unwrap_or_else(|_| "https://plc.directory".to_string());
268268+ let plc_source = PlcSource::PlcDirectory {
269269+ base: plc_source_url.parse().unwrap(),
270270+ };
271271+ let mut resolver = PublicResolver::default();
272272+ resolver = resolver.with_plc_source(plc_source.clone());
149273150274 let state = AppState {
151275 account_pool,
152276 pds_gatekeeper_pool,
153277 reverse_proxy_client: client,
154154- pds_base_url,
155278 mailer,
156156- mailer_from: sent_from,
157279 template_engine: Engine::from(hbs),
280280+ resolver: Arc::new(resolver),
281281+ app_config: AppConfig::new(),
158282 };
159283160284 // Rate limiting
161285 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
162162- let create_session_governor_conf = GovernorConfigBuilder::default()
286286+ let captcha_governor_conf = GovernorConfigBuilder::default()
163287 .per_second(60)
164288 .burst_size(5)
289289+ .key_extractor(SmartIpKeyExtractor)
165290 .finish()
166166- .expect("failed to create governor config. this should not happen and is a bug");
291291+ .expect("failed to create governor config for create session. this should not happen and is a bug");
167292168293 // Create a second config with the same settings for the other endpoint
169294 let sign_in_governor_conf = GovernorConfigBuilder::default()
170295 .per_second(60)
171296 .burst_size(5)
297297+ .key_extractor(SmartIpKeyExtractor)
172298 .finish()
173173- .expect("failed to create governor config. this should not happen and is a bug");
299299+ .expect(
300300+ "failed to create governor config for sign in. this should not happen and is a bug",
301301+ );
302302+303303+ let create_account_limiter_time: Option<String> =
304304+ env::var("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND").ok();
305305+ let create_account_limiter_burst: Option<String> =
306306+ env::var("GATEKEEPER_CREATE_ACCOUNT_BURST").ok();
307307+308308+ //Default should be 608 requests per 5 minutes, PDS is 300 per 500 so will never hit it ideally
309309+ let mut create_account_governor_conf = GovernorConfigBuilder::default();
310310+ if create_account_limiter_time.is_some() {
311311+ let time = create_account_limiter_time
312312+ .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND not set")
313313+ .parse::<u64>()
314314+ .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND must be a valid integer");
315315+ create_account_governor_conf.per_second(time);
316316+ }
317317+318318+ if create_account_limiter_burst.is_some() {
319319+ let burst = create_account_limiter_burst
320320+ .expect("GATEKEEPER_CREATE_ACCOUNT_BURST not set")
321321+ .parse::<u32>()
322322+ .expect("GATEKEEPER_CREATE_ACCOUNT_BURST must be a valid integer");
323323+ create_account_governor_conf.burst_size(burst);
324324+ }
325325+326326+ let create_account_governor_conf = create_account_governor_conf
327327+ .key_extractor(SmartIpKeyExtractor)
328328+ .finish().expect(
329329+ "failed to create governor config for create account. this should not happen and is a bug",
330330+ );
174331175175- let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
332332+ let captcha_governor_limiter = captcha_governor_conf.limiter().clone();
176333 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
334334+ let create_account_governor_limiter = create_account_governor_conf.limiter().clone();
335335+336336+ let sign_in_governor_layer = GovernorLayer::new(sign_in_governor_conf);
337337+177338 let interval = Duration::from_secs(60);
178339 // a separate background task to clean up
179340 std::thread::spawn(move || {
180341 loop {
181342 std::thread::sleep(interval);
182182- create_session_governor_limiter.retain_recent();
343343+ captcha_governor_limiter.retain_recent();
183344 sign_in_governor_limiter.retain_recent();
345345+ create_account_governor_limiter.retain_recent();
184346 }
185347 });
186348···189351 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
190352 .allow_headers(Any);
191353192192- let app = Router::new()
354354+ let mut app = Router::new()
193355 .route("/", get(root_handler))
356356+ .route("/xrpc/com.atproto.server.getSession", get(get_session))
194357 .route(
195195- "/xrpc/com.atproto.server.getSession",
196196- get(get_session).layer(ax_middleware::from_fn(middleware::extract_did)),
358358+ "/xrpc/com.atproto.server.describeServer",
359359+ get(describe_server),
197360 )
198361 .route(
199362 "/xrpc/com.atproto.server.updateEmail",
···201364 )
202365 .route(
203366 "/@atproto/oauth-provider/~api/sign-in",
204204- post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
367367+ post(sign_in).layer(sign_in_governor_layer.clone()),
205368 )
206369 .route(
207370 "/xrpc/com.atproto.server.createSession",
208208- post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
371371+ post(create_session.layer(sign_in_governor_layer)),
209372 )
373373+ .route(
374374+ "/xrpc/com.atproto.server.createAccount",
375375+ post(create_account).layer(GovernorLayer::new(create_account_governor_conf)),
376376+ );
377377+378378+ if state.app_config.use_captcha {
379379+ app = app.route(
380380+ "/gate/signup",
381381+ get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))),
382382+ );
383383+ }
384384+385385+ let app = app
210386 .layer(CompressionLayer::new())
211387 .layer(cors)
212388 .with_state(state);
213389214214- let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
390390+ let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
215391 let port: u16 = env::var("GATEKEEPER_PORT")
216392 .ok()
217393 .and_then(|s| s.parse().ok())
+73-39
src/middleware.rs
···1212#[derive(Clone, Debug)]
1313pub struct Did(pub Option<String>);
14141515+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1616+pub enum AuthScheme {
1717+ Bearer,
1818+ DPoP,
1919+}
2020+1521#[derive(Serialize, Deserialize)]
1622pub struct TokenClaims {
1723 pub sub: String,
1824}
19252026pub async fn extract_did(mut req: Request, next: Next) -> impl IntoResponse {
2121- let token = extract_bearer(req.headers());
2727+ let auth = extract_auth(req.headers());
22282323- match token {
2424- Ok(token) => {
2525- match token {
2929+ match auth {
3030+ Ok(auth_opt) => {
3131+ match auth_opt {
2632 None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
2733 .expect("Error creating an error response"),
2828- Some(token) => {
2929- let token = UntrustedToken::new(&token);
3030- if token.is_err() {
3131- return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
3232- .expect("Error creating an error response");
3333- }
3434- let parsed_token = token.expect("Already checked for error");
3535- let claims: Result<Claims<TokenClaims>, ValidationError> =
3636- parsed_token.deserialize_claims_unchecked();
3737- if claims.is_err() {
3838- return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
3939- .expect("Error creating an error response");
4040- }
3434+ Some((scheme, token_str)) => {
3535+ // For Bearer, validate JWT and extract DID from `sub`.
3636+ // For DPoP, we currently only pass through and do not validate here; insert None DID.
3737+ match scheme {
3838+ AuthScheme::Bearer => {
3939+ let token = UntrustedToken::new(&token_str);
4040+ if token.is_err() {
4141+ return json_error_response(
4242+ StatusCode::BAD_REQUEST,
4343+ "TokenRequired",
4444+ "",
4545+ )
4646+ .expect("Error creating an error response");
4747+ }
4848+ let parsed_token = token.expect("Already checked for error");
4949+ let claims: Result<Claims<TokenClaims>, ValidationError> =
5050+ parsed_token.deserialize_claims_unchecked();
5151+ if claims.is_err() {
5252+ return json_error_response(
5353+ StatusCode::BAD_REQUEST,
5454+ "TokenRequired",
5555+ "",
5656+ )
5757+ .expect("Error creating an error response");
5858+ }
41594242- let key = Hs256Key::new(
4343- env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"),
4444- );
4545- let token: Result<Token<TokenClaims>, ValidationError> =
4646- Hs256.validator(&key).validate(&parsed_token);
4747- if token.is_err() {
4848- return json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
4949- .expect("Error creating an error response");
6060+ let key = Hs256Key::new(
6161+ env::var("PDS_JWT_SECRET")
6262+ .expect("PDS_JWT_SECRET not set in the pds.env"),
6363+ );
6464+ let token: Result<Token<TokenClaims>, ValidationError> =
6565+ Hs256.validator(&key).validate(&parsed_token);
6666+ if token.is_err() {
6767+ return json_error_response(
6868+ StatusCode::BAD_REQUEST,
6969+ "InvalidToken",
7070+ "",
7171+ )
7272+ .expect("Error creating an error response");
7373+ }
7474+ let token = token.expect("Already checked for error,");
7575+ req.extensions_mut()
7676+ .insert(Did(Some(token.claims().custom.sub.clone())));
7777+ }
7878+ AuthScheme::DPoP => {
7979+ //Not going to worry about oauth email update for now, just always forward to the PDS
8080+ req.extensions_mut().insert(Did(None));
8181+ }
5082 }
5151- let token = token.expect("Already checked for error,");
5252- //Not going to worry about expiration since it still goes to the PDS
5353- req.extensions_mut()
5454- .insert(Did(Some(token.claims().custom.sub.clone())));
8383+5584 next.run(req).await
5685 }
5786 }
···6493 }
6594}
66956767-fn extract_bearer(headers: &HeaderMap) -> Result<Option<String>, String> {
9696+fn extract_auth(headers: &HeaderMap) -> Result<Option<(AuthScheme, String)>, String> {
6897 match headers.get(axum::http::header::AUTHORIZATION) {
6998 None => Ok(None),
7070- Some(hv) => match hv.to_str() {
7171- Err(_) => Err("Authorization header is not valid".into()),
7272- Ok(s) => {
7373- // Accept forms like: "Bearer <token>" (case-sensitive for the scheme here)
7474- let mut parts = s.splitn(2, ' ');
7575- match (parts.next(), parts.next()) {
7676- (Some("Bearer"), Some(tok)) if !tok.is_empty() => Ok(Some(tok.to_string())),
7777- _ => Err("Authorization header must be in format 'Bearer <token>'".into()),
9999+ Some(hv) => {
100100+ match hv.to_str() {
101101+ Err(_) => Err("Authorization header is not valid".into()),
102102+ Ok(s) => {
103103+ // Accept forms like: "Bearer <token>" or "DPoP <token>" (case-sensitive for the scheme here)
104104+ let mut parts = s.splitn(2, ' ');
105105+ match (parts.next(), parts.next()) {
106106+ (Some("Bearer"), Some(tok)) if !tok.is_empty() =>
107107+ Ok(Some((AuthScheme::Bearer, tok.to_string()))),
108108+ (Some("DPoP"), Some(tok)) if !tok.is_empty() =>
109109+ Ok(Some((AuthScheme::DPoP, tok.to_string()))),
110110+ _ => Err("Authorization header must be in format 'Bearer <token>' or 'DPoP <token>'".into()),
111111+ }
78112 }
79113 }
8080- },
114114+ }
81115 }
82116}
+4-6
src/oauth_provider.rs
···1313pub struct SignInRequest {
1414 pub username: String,
1515 pub password: String,
1616- pub remember: bool,
1616+ #[serde(skip_serializing_if = "Option::is_none")]
1717+ pub remember: Option<bool>,
1718 pub locale: String,
1819 #[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")]
1920 pub email_otp: Option<String>,
···3637 "Invalid identifier or password",
3738 ),
3839 AuthResult::TwoFactorRequired(masked_email) => {
3939- // Email sending step can be handled here if needed in the future.
4040-4141- // {"error":"second_authentication_factor_required","error_description":"emailOtp authentication factor required (hint: 2***0@p***m)","type":"emailOtp","hint":"2***0@p***m"}
4240 let body_str = match serde_json::to_string(&serde_json::json!({
4341 "error": "second_authentication_factor_required",
4442 "error_description": format!("emailOtp authentication factor required (hint: {})", masked_email),
···5957 //No 2FA or already passed
6058 let uri = format!(
6159 "{}{}",
6262- state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
6060+ state.app_config.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
6361 );
64626563 let mut req = axum::http::Request::post(uri);
···9795 },
9896 Err(err) => {
9997 log::error!(
100100- "Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}"
9898+ "Error during pre-auth check. This happens on the oauth signin endpoint when trying to decide if the user has access:\n {err}"
10199 );
102100 oauth_json_error_response(
103101 StatusCode::BAD_REQUEST,
+394-53
src/xrpc/com_atproto_server.rs
···11use crate::AppState;
22use crate::helpers::{
33- AuthResult, ProxiedResult, TokenCheckError, json_error_response, preauth_check, proxy_get_json,
33+ AuthResult, ProxiedResult, TokenCheckError, VerifyServiceAuthError, json_error_response,
44+ preauth_check, proxy_get_json, verify_gate_token, verify_service_auth,
45};
56use crate::middleware::Did;
66-use axum::body::Body;
77+use axum::body::{Body, to_bytes};
78use axum::extract::State;
88-use axum::http::{HeaderMap, StatusCode};
99+use axum::http::{HeaderMap, StatusCode, header};
910use axum::response::{IntoResponse, Response};
1011use axum::{Extension, Json, debug_handler, extract, extract::Request};
1212+use chrono::{Duration, Utc};
1313+use jacquard_common::types::did::Did as JacquardDid;
1114use serde::{Deserialize, Serialize};
1215use serde_json;
1316use tracing::log;
···6164 allow_takendown: Option<bool>,
6265}
63666767+#[derive(Deserialize, Serialize, Debug)]
6868+#[serde(rename_all = "camelCase")]
6969+pub struct CreateAccountRequest {
7070+ handle: String,
7171+ #[serde(skip_serializing_if = "Option::is_none")]
7272+ email: Option<String>,
7373+ #[serde(skip_serializing_if = "Option::is_none")]
7474+ password: Option<String>,
7575+ #[serde(skip_serializing_if = "Option::is_none")]
7676+ did: Option<String>,
7777+ #[serde(skip_serializing_if = "Option::is_none")]
7878+ invite_code: Option<String>,
7979+ #[serde(skip_serializing_if = "Option::is_none")]
8080+ verification_code: Option<String>,
8181+ #[serde(skip_serializing_if = "Option::is_none")]
8282+ plc_op: Option<serde_json::Value>,
8383+}
8484+8585+#[derive(Deserialize, Serialize, Debug, Clone)]
8686+#[serde(rename_all = "camelCase")]
8787+pub struct DescribeServerContact {
8888+ #[serde(skip_serializing_if = "Option::is_none")]
8989+ email: Option<String>,
9090+}
9191+9292+#[derive(Deserialize, Serialize, Debug, Clone)]
9393+#[serde(rename_all = "camelCase")]
9494+pub struct DescribeServerLinks {
9595+ #[serde(skip_serializing_if = "Option::is_none")]
9696+ privacy_policy: Option<String>,
9797+ #[serde(skip_serializing_if = "Option::is_none")]
9898+ terms_of_service: Option<String>,
9999+}
100100+101101+#[derive(Deserialize, Serialize, Debug, Clone)]
102102+#[serde(rename_all = "camelCase")]
103103+pub struct DescribeServerResponse {
104104+ #[serde(skip_serializing_if = "Option::is_none")]
105105+ invite_code_required: Option<bool>,
106106+ #[serde(skip_serializing_if = "Option::is_none")]
107107+ phone_verification_required: Option<bool>,
108108+ #[serde(skip_serializing_if = "Option::is_none")]
109109+ available_user_domains: Option<Vec<String>>,
110110+ #[serde(skip_serializing_if = "Option::is_none")]
111111+ links: Option<DescribeServerLinks>,
112112+ #[serde(skip_serializing_if = "Option::is_none")]
113113+ contact: Option<DescribeServerContact>,
114114+ #[serde(skip_serializing_if = "Option::is_none")]
115115+ did: Option<String>,
116116+}
117117+64118pub async fn create_session(
65119 State(state): State<AppState>,
66120 headers: HeaderMap,
···87141 )
88142 }
89143 AuthResult::ProxyThrough => {
9090- log::info!("Proxying through");
91144 //No 2FA or already passed
92145 let uri = format!(
93146 "{}{}",
9494- state.pds_base_url, "/xrpc/com.atproto.server.createSession"
147147+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.createSession"
95148 );
9614997150 let mut req = axum::http::Request::post(uri);
···148201 //If email auth is set it is to either turn on or off 2fa
149202 let email_auth_update = payload.email_auth_factor.unwrap_or(false);
150203151151- // Email update asked for
152152- if email_auth_update {
153153- let email = payload.email.clone();
154154- let email_confirmed = sqlx::query_as::<_, (String,)>(
155155- "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?",
156156- )
157157- .bind(&email)
158158- .fetch_optional(&state.account_pool)
159159- .await
160160- .map_err(|_| StatusCode::BAD_REQUEST)?;
161161-162162- //Since the email is already confirmed we can enable 2fa
163163- return match email_confirmed {
164164- None => Err(StatusCode::BAD_REQUEST),
165165- Some(did_row) => {
166166- let _ = sqlx::query(
167167- "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1",
168168- )
169169- .bind(&did_row.0)
170170- .execute(&state.pds_gatekeeper_pool)
171171- .await
172172- .map_err(|_| StatusCode::BAD_REQUEST)?;
173173-174174- Ok(StatusCode::OK.into_response())
175175- }
176176- };
177177- }
204204+ //This means the middleware successfully extracted a did from the request, if not it just needs to be forward to the PDS
205205+ //This is also empty if it is an oauth request, which is not supported by gatekeeper turning on 2fa since the dpop stuff needs to be implemented
206206+ let did_is_not_empty = did.0.is_some();
178207179179- // User wants auth turned off
180180- if !email_auth_update && !email_auth_not_set {
181181- //User wants auth turned off and has a token
182182- if let Some(token) = &payload.token {
183183- let token_found = sqlx::query_as::<_, (String,)>(
184184- "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'",
208208+ if did_is_not_empty {
209209+ // Email update asked for
210210+ if email_auth_update {
211211+ let email = payload.email.clone();
212212+ let email_confirmed = match sqlx::query_as::<_, (String,)>(
213213+ "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?",
185214 )
186186- .bind(token)
187187- .bind(&did.0)
215215+ .bind(&email)
188216 .fetch_optional(&state.account_pool)
189217 .await
190190- .map_err(|_| StatusCode::BAD_REQUEST)?;
218218+ {
219219+ Ok(row) => row,
220220+ Err(err) => {
221221+ log::error!("Error checking if email is confirmed: {err}");
222222+ return Err(StatusCode::BAD_REQUEST);
223223+ }
224224+ };
191225192192- if token_found.is_some() {
193193- let _ = sqlx::query(
194194- "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0",
226226+ //Since the email is already confirmed we can enable 2fa
227227+ return match email_confirmed {
228228+ None => Err(StatusCode::BAD_REQUEST),
229229+ Some(did_row) => {
230230+ let _ = sqlx::query(
231231+ "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1",
232232+ )
233233+ .bind(&did_row.0)
234234+ .execute(&state.pds_gatekeeper_pool)
235235+ .await
236236+ .map_err(|_| StatusCode::BAD_REQUEST)?;
237237+238238+ Ok(StatusCode::OK.into_response())
239239+ }
240240+ };
241241+ }
242242+243243+ // User wants auth turned off
244244+ if !email_auth_update && !email_auth_not_set {
245245+ //User wants auth turned off and has a token
246246+ if let Some(token) = &payload.token {
247247+ let token_found = match sqlx::query_as::<_, (String,)>(
248248+ "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'",
195249 )
196196- .bind(&did.0)
197197- .execute(&state.pds_gatekeeper_pool)
198198- .await
199199- .map_err(|_| StatusCode::BAD_REQUEST)?;
250250+ .bind(token)
251251+ .bind(&did.0)
252252+ .fetch_optional(&state.account_pool)
253253+ .await{
254254+ Ok(token) => token,
255255+ Err(err) => {
256256+ log::error!("Error checking if token is valid: {err}");
257257+ return Err(StatusCode::BAD_REQUEST);
258258+ }
259259+ };
200260201201- return Ok(StatusCode::OK.into_response());
202202- } else {
203203- return Err(StatusCode::BAD_REQUEST);
261261+ return if token_found.is_some() {
262262+ //TODO I think there may be a bug here and need to do some retry logic
263263+ // First try was erroring, seconds was allowing
264264+ match sqlx::query(
265265+ "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0",
266266+ )
267267+ .bind(&did.0)
268268+ .execute(&state.pds_gatekeeper_pool)
269269+ .await {
270270+ Ok(_) => {}
271271+ Err(err) => {
272272+ log::error!("Error updating email auth: {err}");
273273+ return Err(StatusCode::BAD_REQUEST);
274274+ }
275275+ }
276276+277277+ Ok(StatusCode::OK.into_response())
278278+ } else {
279279+ Err(StatusCode::BAD_REQUEST)
280280+ };
204281 }
205282 }
206283 }
207207-208284 // Updating the actual email address by sending it on to the PDS
209285 let uri = format!(
210286 "{}{}",
211211- state.pds_base_url, "/xrpc/com.atproto.server.updateEmail"
287287+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.updateEmail"
212288 );
213289 let mut req = axum::http::Request::post(uri);
214290 if let Some(req_headers) = req.headers_mut() {
···260336 ProxiedResult::Passthrough(resp) => Ok(resp),
261337 }
262338}
339339+340340+pub async fn describe_server(
341341+ State(state): State<AppState>,
342342+ req: Request,
343343+) -> Result<Response<Body>, StatusCode> {
344344+ match proxy_get_json::<DescribeServerResponse>(
345345+ &state,
346346+ req,
347347+ "/xrpc/com.atproto.server.describeServer",
348348+ )
349349+ .await?
350350+ {
351351+ ProxiedResult::Parsed {
352352+ value: mut server_info,
353353+ ..
354354+ } => {
355355+ //This signifies the server is configured for captcha verification
356356+ server_info.phone_verification_required = Some(state.app_config.use_captcha);
357357+ Ok(Json(server_info).into_response())
358358+ }
359359+ ProxiedResult::Passthrough(resp) => Ok(resp),
360360+ }
361361+}
362362+363363+/// Verify a gate code matches the handle and is not expired
364364+async fn verify_gate_code(
365365+ state: &AppState,
366366+ code: &str,
367367+ handle: &str,
368368+) -> Result<bool, anyhow::Error> {
369369+ // First, decrypt and verify the JWE token
370370+ let payload = match verify_gate_token(code, &state.app_config.gate_jwe_key) {
371371+ Ok(p) => p,
372372+ Err(e) => {
373373+ log::warn!("Failed to decrypt gate token: {}", e);
374374+ return Ok(false);
375375+ }
376376+ };
377377+378378+ // Verify the handle matches
379379+ if payload.handle != handle {
380380+ log::warn!(
381381+ "Gate code handle mismatch: expected {}, got {}",
382382+ handle,
383383+ payload.handle
384384+ );
385385+ return Ok(false);
386386+ }
387387+388388+ let created_at = chrono::DateTime::parse_from_rfc3339(&payload.created_at)
389389+ .map_err(|e| anyhow::anyhow!("Failed to parse created_at from token: {}", e))?
390390+ .with_timezone(&Utc);
391391+392392+ let now = Utc::now();
393393+ let age = now - created_at;
394394+395395+ // Check if the token is expired (5 minutes)
396396+ if age > Duration::minutes(5) {
397397+ log::warn!("Gate code expired for handle {}", handle);
398398+ return Ok(false);
399399+ }
400400+401401+ // Verify the token exists in the database (to prevent reuse)
402402+ let row: Option<(String,)> =
403403+ sqlx::query_as("SELECT code FROM gate_codes WHERE code = ? and handle = ? LIMIT 1")
404404+ .bind(code)
405405+ .bind(handle)
406406+ .fetch_optional(&state.pds_gatekeeper_pool)
407407+ .await?;
408408+409409+ if row.is_none() {
410410+ log::warn!("Gate code not found in database or already used");
411411+ return Ok(false);
412412+ }
413413+414414+ // Token is valid, delete it so it can't be reused
415415+ //TODO probably also delete expired codes? Will need to do that at some point probably altho the where is on code and handle
416416+417417+ sqlx::query("DELETE FROM gate_codes WHERE code = ?")
418418+ .bind(code)
419419+ .execute(&state.pds_gatekeeper_pool)
420420+ .await?;
421421+422422+ Ok(true)
423423+}
424424+425425+pub async fn create_account(
426426+ State(state): State<AppState>,
427427+ req: Request,
428428+) -> Result<Response<Body>, StatusCode> {
429429+ let headers = req.headers().clone();
430430+ let body_bytes = to_bytes(req.into_body(), usize::MAX)
431431+ .await
432432+ .map_err(|_| StatusCode::BAD_REQUEST)?;
433433+434434+ // Parse the body to check for verification code
435435+ let account_request: CreateAccountRequest =
436436+ serde_json::from_slice(&body_bytes).map_err(|e| {
437437+ log::error!("Failed to parse create account request: {}", e);
438438+ StatusCode::BAD_REQUEST
439439+ })?;
440440+441441+ // Check for service auth (migrations) if configured
442442+ if state.app_config.allow_only_migrations {
443443+ // Expect Authorization: Bearer <jwt>
444444+ let auth_header = headers
445445+ .get(header::AUTHORIZATION)
446446+ .and_then(|v| v.to_str().ok())
447447+ .map(str::to_string);
448448+449449+ let Some(value) = auth_header else {
450450+ log::error!("No Authorization header found in the request");
451451+ return json_error_response(
452452+ StatusCode::UNAUTHORIZED,
453453+ "InvalidAuth",
454454+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
455455+ );
456456+ };
457457+458458+ // Ensure Bearer prefix
459459+ let token = value.strip_prefix("Bearer ").unwrap_or("").trim();
460460+ if token.is_empty() {
461461+ log::error!("No Service Auth token found in the Authorization header");
462462+ return json_error_response(
463463+ StatusCode::UNAUTHORIZED,
464464+ "InvalidAuth",
465465+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
466466+ );
467467+ }
468468+469469+ // Ensure a non-empty DID was provided when migrations are enabled
470470+ let requested_did_str = match account_request.did.as_deref() {
471471+ Some(s) if !s.trim().is_empty() => s,
472472+ _ => {
473473+ return json_error_response(
474474+ StatusCode::BAD_REQUEST,
475475+ "InvalidRequest",
476476+ "The 'did' field is required when migrations are enforced.",
477477+ );
478478+ }
479479+ };
480480+481481+ // Parse the DID into the expected type for verification
482482+ let requested_did: JacquardDid<'static> = match requested_did_str.parse() {
483483+ Ok(d) => d,
484484+ Err(e) => {
485485+ log::error!(
486486+ "Invalid DID format provided in createAccount: {} | error: {}",
487487+ requested_did_str,
488488+ e
489489+ );
490490+ return json_error_response(
491491+ StatusCode::BAD_REQUEST,
492492+ "InvalidRequest",
493493+ "The 'did' field is not a valid DID.",
494494+ );
495495+ }
496496+ };
497497+498498+ let nsid = "com.atproto.server.createAccount".parse().unwrap();
499499+ match verify_service_auth(
500500+ token,
501501+ &nsid,
502502+ state.resolver.clone(),
503503+ &state.app_config.pds_service_did,
504504+ &requested_did,
505505+ )
506506+ .await
507507+ {
508508+ //Just do nothing if it passes so it continues.
509509+ Ok(_) => {}
510510+ Err(err) => match err {
511511+ VerifyServiceAuthError::AuthFailed => {
512512+ return json_error_response(
513513+ StatusCode::UNAUTHORIZED,
514514+ "InvalidAuth",
515515+ "This PDS is configured to only allow accounts created by migrations via this endpoint.",
516516+ );
517517+ }
518518+ VerifyServiceAuthError::Error(err) => {
519519+ log::error!("Error verifying service auth token: {err}");
520520+ return json_error_response(
521521+ StatusCode::BAD_REQUEST,
522522+ "InvalidRequest",
523523+ "There has been an error, please contact your PDS administrator for help and for them to review the server logs.",
524524+ );
525525+ }
526526+ },
527527+ }
528528+ }
529529+530530+ // Check for captcha verification if configured
531531+ if state.app_config.use_captcha {
532532+ if let Some(ref verification_code) = account_request.verification_code {
533533+ match verify_gate_code(&state, verification_code, &account_request.handle).await {
534534+ //TODO has a few errors to support
535535+536536+ //expired token
537537+ // {
538538+ // "error": "ExpiredToken",
539539+ // "message": "Token has expired"
540540+ // }
541541+542542+ //TODO ALSO add rate limits on the /gate endpoints so they can't be abused
543543+ Ok(true) => {
544544+ log::info!("Gate code verified for handle: {}", account_request.handle);
545545+ }
546546+ Ok(false) => {
547547+ log::warn!(
548548+ "Invalid or expired gate code for handle: {}",
549549+ account_request.handle
550550+ );
551551+ return json_error_response(
552552+ StatusCode::BAD_REQUEST,
553553+ "InvalidToken",
554554+ "Token could not be verified",
555555+ );
556556+ }
557557+ Err(e) => {
558558+ log::error!("Error verifying gate code: {}", e);
559559+ return json_error_response(
560560+ StatusCode::INTERNAL_SERVER_ERROR,
561561+ "InvalidToken",
562562+ "Token could not be verified",
563563+ );
564564+ }
565565+ }
566566+ } else {
567567+ // No verification code provided but captcha is required
568568+ log::warn!(
569569+ "No verification code provided for account creation: {}",
570570+ account_request.handle
571571+ );
572572+ return json_error_response(
573573+ StatusCode::BAD_REQUEST,
574574+ "InvalidRequest",
575575+ "Verification is now required on this server.",
576576+ );
577577+ }
578578+ }
579579+580580+ // Rebuild the request with the same body and headers
581581+ let uri = format!(
582582+ "{}{}",
583583+ state.app_config.pds_base_url, "/xrpc/com.atproto.server.createAccount"
584584+ );
585585+586586+ let mut new_req = axum::http::Request::post(&uri);
587587+ if let Some(req_headers) = new_req.headers_mut() {
588588+ *req_headers = headers;
589589+ }
590590+591591+ let new_req = new_req
592592+ .body(Body::from(body_bytes))
593593+ .map_err(|_| StatusCode::BAD_REQUEST)?;
594594+595595+ let proxied = state
596596+ .reverse_proxy_client
597597+ .request(new_req)
598598+ .await
599599+ .map_err(|_| StatusCode::BAD_REQUEST)?
600600+ .into_response();
601601+602602+ Ok(proxied)
603603+}