···11[package]
22name = "pds_gatekeeper"
33-version = "0.1.0"
33+version = "0.1.2"
44edition = "2024"
55+license = "MIT"
5667[dependencies]
78axum = { version = "0.8.4", features = ["macros", "json"] }
···1415tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
1516hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
1617tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
1717-tower_governor = "0.8.0"
1818+tower_governor = { version = "0.8.0", features = ["axum", "tracing"] }
1819hex = "0.4"
1920jwt-compact = { version = "0.8.0", features = ["es256k"] }
2021scrypt = "0.11"
2121-#lettre = { version = "0.11.18", default-features = false, features = ["pool", "tokio1-rustls", "smtp-transport", "hostname", "builder"] }
2222-#lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
2222+#Leaveing these two cause I think it is needed by the email crate for ssl
2323aws-lc-rs = "1.13.0"
2424+rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
2425lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
2525-rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
2626handlebars = { version = "6.3.2", features = ["rust-embed"] }
2727rust-embed = "8.7.2"
2828axum-template = { version = "3.0.0", features = ["handlebars"] }
2929rand = "0.9.2"
3030anyhow = "1.0.99"
3131-chrono = "0.4.41"
3131+chrono = { version = "0.4.42", features = ["default", "serde"] }
3232sha2 = "0.10"
3333+jacquard-common = "0.9.2"
3434+jacquard-identity = "0.9.2"
3535+multibase = "0.9.2"
3636+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
3737+urlencoding = "2.1"
3838+html-escape = "0.2.13"
3939+josekit = "0.10.3"
+21
LICENSE.md
···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.
+118-23
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
2350···3764```yml
3865 gatekeeper:
3966 container_name: gatekeeper
4040- image: fatfingers23/pds_gatekeeper:arm-latest
6767+ image: fatfingers23/pds_gatekeeper:latest
4168 network_mode: host
4269 restart: unless-stopped
4370 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···4976 - pds
5077```
51787979+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+```
123123+52124## Caddy setup
5312554126For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
55127in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
56128This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
571295858-```caddyfile
130130+```
59131 @gatekeeper {
6060- path /xrpc/com.atproto.server.getSession
6161- path /xrpc/com.atproto.server.updateEmail
6262- path /xrpc/com.atproto.server.createSession
6363- path /@atproto/oauth-provider/~api/sign-in
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/*
64139 }
6514066141 handle @gatekeeper {
6767- reverse_proxy http://localhost:8080
6868- }
142142+ reverse_proxy http://localhost:8080
143143+ }
691447070- reverse_proxy http://localhost:3000
145145+ reverse_proxy http://localhost:3000
71146```
7214773148If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
74149`localhost:8081` (or w/e port you want).
751507676-```caddyfile
151151+```
77152http://*.localhost:8082, http://localhost:8082 {
7878- @gatekeeper {
7979- path /xrpc/com.atproto.server.getSession
8080- path /xrpc/com.atproto.server.updateEmail
8181- path /xrpc/com.atproto.server.createSession
8282- path /@atproto/oauth-provider/~api/sign-in
8383- }
8484-8585- handle @gatekeeper {
8686- reverse_proxy http://localhost:8080
8787- }
153153+ @gatekeeper {
154154+ path /xrpc/com.atproto.server.getSession
155155+ path /xrpc/com.atproto.server.describeServer
156156+ path /xrpc/com.atproto.server.updateEmail
157157+ path /xrpc/com.atproto.server.createSession
158158+ path /xrpc/com.atproto.server.createAccount
159159+ path /@atproto/oauth-provider/~api/sign-in
160160+ path /gate/*
161161+ }
881628989- reverse_proxy http://localhost:3000
163163+ handle @gatekeeper {
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}
168168+ }
169169+ reverse_proxy http://localhost:3000
90170}
9117192172```
···105185in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the
106186same.
107187188188+`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+108191`PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default
109192110193`GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1`
111194112195`GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080`
196196+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+
+22-21
examples/Caddyfile
···11{
22- email youremail@myemail.com
33- on_demand_tls {
44- ask http://localhost:3000/tls-check
55- }
22+ email youremail@myemail.com
33+ on_demand_tls {
44+ ask http://localhost:3000/tls-check
55+ }
66}
7788*.yourpds.com, yourpds.com {
99- tls {
1010- on_demand
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.updateEmail
1616- path /xrpc/com.atproto.server.createSession
1717- path /@atproto/oauth-provider/~api/sign-in
1818- }
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+ }
19222020- handle @gatekeeper {
2121- #This is the address for PDS gatekeeper, default is 8080
2222- reverse_proxy http://localhost:8080
2323- }
2323+ handle @gatekeeper {
2424+ #This is the address for PDS gatekeeper, default is 8080
2525+ reverse_proxy http://localhost:8080
2626+ }
24272525- reverse_proxy http://localhost:3000
2626- #..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
2828+ reverse_proxy http://localhost:3000
2929+#..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
2730}
2828-2929-
+1-1
examples/compose.yml
···3939 WATCHTOWER_SCHEDULE: "@midnight"
4040 gatekeeper:
4141 container_name: gatekeeper
4242- image: fatfingers23/pds_gatekeeper:arm-latest
4242+ image: fatfingers23/pds_gatekeeper:latest
4343 network_mode: host
4444 restart: unless-stopped
4545 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···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+}
+207-34
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 {
···91199 let pds_env_location =
92200 env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string());
932019494- dotenvy::from_path(Path::new(&pds_env_location))?;
9595- let pds_root = env::var("PDS_DATA_DIRECTORY")?;
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");
96211 let account_db_url = format!("{pds_root}/account.sqlite");
9721298213 let account_options = SqliteConnectOptions::new()
···129244 //Emailer set up
130245 let smtp_url =
131246 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
132132- let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS")
133133- .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
134247135248 let mailer: AsyncSmtpTransport<Tokio1Executor> =
136249 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
···147260 let _ = hbs.register_embed_templates::<EmailTemplates>();
148261 }
149262150150- let pds_base_url =
151151- 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());
152273153274 let state = AppState {
154275 account_pool,
155276 pds_gatekeeper_pool,
156277 reverse_proxy_client: client,
157157- pds_base_url,
158278 mailer,
159159- mailer_from: sent_from,
160279 template_engine: Engine::from(hbs),
280280+ resolver: Arc::new(resolver),
281281+ app_config: AppConfig::new(),
161282 };
162283163284 // Rate limiting
164285 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
165165- let create_session_governor_conf = GovernorConfigBuilder::default()
286286+ let captcha_governor_conf = GovernorConfigBuilder::default()
166287 .per_second(60)
167288 .burst_size(5)
289289+ .key_extractor(SmartIpKeyExtractor)
168290 .finish()
169169- .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");
170292171293 // Create a second config with the same settings for the other endpoint
172294 let sign_in_governor_conf = GovernorConfigBuilder::default()
173295 .per_second(60)
174296 .burst_size(5)
297297+ .key_extractor(SmartIpKeyExtractor)
175298 .finish()
176176- .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+ );
177302178178- let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
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+ );
331331+332332+ let captcha_governor_limiter = captcha_governor_conf.limiter().clone();
179333 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+180338 let interval = Duration::from_secs(60);
181339 // a separate background task to clean up
182340 std::thread::spawn(move || {
183341 loop {
184342 std::thread::sleep(interval);
185185- create_session_governor_limiter.retain_recent();
343343+ captcha_governor_limiter.retain_recent();
186344 sign_in_governor_limiter.retain_recent();
345345+ create_account_governor_limiter.retain_recent();
187346 }
188347 });
189348···192351 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
193352 .allow_headers(Any);
194353195195- let app = Router::new()
354354+ let mut app = Router::new()
196355 .route("/", get(root_handler))
356356+ .route("/xrpc/com.atproto.server.getSession", get(get_session))
197357 .route(
198198- "/xrpc/com.atproto.server.getSession",
199199- get(get_session).layer(ax_middleware::from_fn(middleware::extract_did)),
358358+ "/xrpc/com.atproto.server.describeServer",
359359+ get(describe_server),
200360 )
201361 .route(
202362 "/xrpc/com.atproto.server.updateEmail",
···204364 )
205365 .route(
206366 "/@atproto/oauth-provider/~api/sign-in",
207207- post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
367367+ post(sign_in).layer(sign_in_governor_layer.clone()),
208368 )
209369 .route(
210370 "/xrpc/com.atproto.server.createSession",
211211- post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
371371+ post(create_session.layer(sign_in_governor_layer)),
212372 )
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
213386 .layer(CompressionLayer::new())
214387 .layer(cors)
215388 .with_state(state);
216389217217- 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());
218391 let port: u16 = env::var("GATEKEEPER_PORT")
219392 .ok()
220393 .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}