···1[package]
2name = "pds_gatekeeper"
3-version = "0.1.0"
4edition = "2024"
056[dependencies]
7axum = { version = "0.8.4", features = ["macros", "json"] }
···14tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
15hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
16tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
17-tower_governor = "0.8.0"
18hex = "0.4"
19jwt-compact = { version = "0.8.0", features = ["es256k"] }
20scrypt = "0.11"
21-#lettre = { version = "0.11.18", default-features = false, features = ["pool", "tokio1-rustls", "smtp-transport", "hostname", "builder"] }
22-#lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
23aws-lc-rs = "1.13.0"
024lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
25-rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
26handlebars = { version = "6.3.2", features = ["rust-embed"] }
27rust-embed = "8.7.2"
28axum-template = { version = "3.0.0", features = ["handlebars"] }
29rand = "0.9.2"
30anyhow = "1.0.99"
31-chrono = "0.4.41"
32sha2 = "0.10"
0000000
···1[package]
2name = "pds_gatekeeper"
3+version = "0.1.2"
4edition = "2024"
5+license = "MIT"
67[dependencies]
8axum = { version = "0.8.4", features = ["macros", "json"] }
···15tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
16hyper-util = { version = "0.1.16", features = ["client", "client-legacy"] }
17tower-http = { version = "0.6", features = ["cors", "compression-zstd"] }
18+tower_governor = { version = "0.8.0", features = ["axum", "tracing"] }
19hex = "0.4"
20jwt-compact = { version = "0.8.0", features = ["es256k"] }
21scrypt = "0.11"
22+#Leaveing these two cause I think it is needed by the email crate for ssl
023aws-lc-rs = "1.13.0"
24+rustls = { version = "0.23", default-features = false, features = ["tls12", "std", "logging", "aws_lc_rs"] }
25lettre = { version = "0.11", default-features = false, features = ["builder", "webpki-roots", "rustls", "aws-lc-rs", "smtp-transport", "tokio1", "tokio1-rustls"] }
026handlebars = { version = "6.3.2", features = ["rust-embed"] }
27rust-embed = "8.7.2"
28axum-template = { version = "3.0.0", features = ["handlebars"] }
29rand = "0.9.2"
30anyhow = "1.0.99"
31+chrono = { version = "0.4.42", features = ["default", "serde"] }
32sha2 = "0.10"
33+jacquard-common = "0.9.2"
34+jacquard-identity = "0.9.2"
35+multibase = "0.9.2"
36+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
37+urlencoding = "2.1"
38+html-escape = "0.2.13"
39+josekit = "0.10.3"
+21
LICENSE.md
···000000000000000000000
···1+MIT License
2+3+Copyright (c) 2025 Bailey Townsend
4+5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
+118-23
README.md
···15- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
1718-## Captcha on Create Account
1920-Future feature?
0000000000000000000000000002122# Setup
23···37```yml
38 gatekeeper:
39 container_name: gatekeeper
40- image: fatfingers23/pds_gatekeeper:arm-latest
41 network_mode: host
42 restart: unless-stopped
43 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···49 - pds
50```
5100000000000000000000000000000000000000000000052## Caddy setup
5354For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
55in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
56This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
5758-```caddyfile
59 @gatekeeper {
60- path /xrpc/com.atproto.server.getSession
61- path /xrpc/com.atproto.server.updateEmail
62- path /xrpc/com.atproto.server.createSession
63- path /@atproto/oauth-provider/~api/sign-in
00064 }
6566 handle @gatekeeper {
67- reverse_proxy http://localhost:8080
68- }
6970- reverse_proxy http://localhost:3000
71```
7273If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
74`localhost:8081` (or w/e port you want).
7576-```caddyfile
77http://*.localhost:8082, http://localhost:8082 {
78- @gatekeeper {
79- path /xrpc/com.atproto.server.getSession
80- path /xrpc/com.atproto.server.updateEmail
81- path /xrpc/com.atproto.server.createSession
82- path /@atproto/oauth-provider/~api/sign-in
83- }
84-85- handle @gatekeeper {
86- reverse_proxy http://localhost:8080
87- }
8889- reverse_proxy http://localhost:3000
00000090}
9192```
···105in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the
106same.
107000108`PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default
109110`GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1`
111112`GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080`
000000000000
···15- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
1718+## Captcha on account creation
1920+Require a `verificationCode` set on the `createAccount` request. This is gotten from completing a captcha challenge
21+hosted on the
22+PDS mimicking what the Bluesky Entryway does. Migration tools will need to support this, but social-apps will support
23+and redirect to `GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT`. This is how the clients know to get the code to prove a captcha
24+was successful.
25+26+- Requires `GATEKEEPER_CREATE_ACCOUNT_CAPTCHA` to be set to true.
27+- Requires `PDS_HCAPTCHA_SITE_KEY` and `PDS_HCAPTCHA_SECRET_KEY` to be set. Can sign up at https://www.hcaptcha.com/
28+- Requires proxying `/xrpc/com.atproto.server.describeServer`, `/xrpc/com.atproto.server.createAccount` and `/gate/*` to
29+ PDS
30+ Gatekeeper
31+- Optional `GATEKEEPER_JWE_KEY` key to encrypt the captcha verification code. Defaults to a random 32 byte key. Not
32+ strictly needed unless you're scaling
33+- Optional`GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT` default redirect on captcha success. Defaults to `https://bsky.app`.
34+- Optional `GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS` allowed redirect urls for captcha success. You want these to match the
35+ url showing the captcha. Defaults are:
36+ - https://bsky.app
37+ - https://pdsmoover.com
38+ - https://blacksky.community
39+ - https://tektite.cc
40+41+## Block account creation unless it's a migration
42+43+You can set `GATEKEEPER_ALLOW_ONLY_MIGRATIONS` to block createAccount unless it's via a migration. This does not require
44+a change for migration tools, but social-apps create a new account will no longer work and to create a brand new account
45+users will need to do this via the Oauth account create screen on the PDS. We recommend setting `PDS_HCAPTCHA_SITE_KEY`
46+and `PDS_HCAPTCHA_SECRET_KEY` so the OAuth screen is protected by a captcha if you use this with invite codes turned
47+off.
4849# Setup
50···64```yml
65 gatekeeper:
66 container_name: gatekeeper
67+ image: fatfingers23/pds_gatekeeper:latest
68 network_mode: host
69 restart: unless-stopped
70 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···76 - pds
77```
7879+For Coolify, if you're using Traefik as your proxy you'll need to make sure the labels for the container are set up
80+correctly. A full example can be found at [./examples/coolify-compose.yml](./examples/coolify-compose.yml).
81+82+```yml
83+gatekeeper:
84+ container_name: gatekeeper
85+ image: 'fatfingers23/pds_gatekeeper:latest'
86+ restart: unless-stopped
87+ volumes:
88+ - '/pds:/pds'
89+ environment:
90+ - 'PDS_DATA_DIRECTORY=${PDS_DATA_DIRECTORY:-/pds}'
91+ - 'PDS_BASE_URL=http://pds:3000'
92+ - GATEKEEPER_HOST=0.0.0.0
93+ depends_on:
94+ - pds
95+ healthcheck:
96+ test:
97+ - CMD
98+ - timeout
99+ - '1'
100+ - bash
101+ - '-c'
102+ - 'cat < /dev/null > /dev/tcp/0.0.0.0/8080'
103+ interval: 10s
104+ timeout: 5s
105+ retries: 3
106+ start_period: 10s
107+ labels:
108+ - traefik.enable=true
109+ - 'traefik.http.routers.pds-gatekeeper.rule=Host(`yourpds.com`) && (Path(`/xrpc/com.atproto.server.getSession`) || Path(`/xrpc/com.atproto.server.updateEmail`) || Path(`/xrpc/com.atproto.server.createSession`) || Path(`/xrpc/com.atproto.server.createAccount`) || Path(`/@atproto/oauth-provider/~api/sign-in`))'
110+ - traefik.http.routers.pds-gatekeeper.entrypoints=https
111+ - traefik.http.routers.pds-gatekeeper.tls=true
112+ - traefik.http.routers.pds-gatekeeper.priority=100
113+ - traefik.http.routers.pds-gatekeeper.middlewares=gatekeeper-cors
114+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.port=8080
115+ - traefik.http.services.pds-gatekeeper.loadbalancer.server.scheme=http
116+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS,PATCH'
117+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowheaders=*'
118+ - 'traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolalloworiginlist=*'
119+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolmaxage=100
120+ - traefik.http.middlewares.gatekeeper-cors.headers.addvaryheader=true
121+ - traefik.http.middlewares.gatekeeper-cors.headers.accesscontrolallowcredentials=true
122+```
123+124## Caddy setup
125126For the reverse proxy I use caddy. This part is what overwrites the endpoints and proxies them to PDS gatekeeper to add
127in extra functionality. The main part is below, for a full example see [./examples/Caddyfile](./examples/Caddyfile).
128This is usually found at `/pds/caddy/etc/caddy/Caddyfile` on your PDS.
129130+```
131 @gatekeeper {
132+ path /xrpc/com.atproto.server.getSession
133+ path /xrpc/com.atproto.server.describeServer
134+ path /xrpc/com.atproto.server.updateEmail
135+ path /xrpc/com.atproto.server.createSession
136+ path /xrpc/com.atproto.server.createAccount
137+ path /@atproto/oauth-provider/~api/sign-in
138+ path /gate/*
139 }
140141 handle @gatekeeper {
142+ reverse_proxy http://localhost:8080
143+ }
144145+ reverse_proxy http://localhost:3000
146```
147148If you use a cloudflare tunnel then your caddyfile would look a bit more like below with your tunnel proxying to
149`localhost:8081` (or w/e port you want).
150151+```
152http://*.localhost:8082, http://localhost:8082 {
153+ @gatekeeper {
154+ path /xrpc/com.atproto.server.getSession
155+ path /xrpc/com.atproto.server.describeServer
156+ path /xrpc/com.atproto.server.updateEmail
157+ path /xrpc/com.atproto.server.createSession
158+ path /xrpc/com.atproto.server.createAccount
159+ path /@atproto/oauth-provider/~api/sign-in
160+ path /gate/*
161+ }
0162163+ handle @gatekeeper {
164+ #This is the address for PDS gatekeeper, default is 8080
165+ reverse_proxy http://localhost:8080
166+ #Makes sure the cloudflare ip is proxied and able to be picked up by pds gatekeeper
167+ header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
168+ }
169+ reverse_proxy http://localhost:3000
170}
171172```
···185in the pds gateekeper container and it will use them in place of the default ones. Just make sure ot keep the names the
186same.
187188+`GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT` - Subject of the email sent to the user when they turn on 2FA. Defaults to
189+`Sign in to Bluesky`
190+191`PDS_BASE_URL` - Base url of the PDS. You most likely want `https://localhost:3000` which is also the default
192193`GATEKEEPER_HOST` - Host for pds gatekeeper. Defaults to `127.0.0.1`
194195`GATEKEEPER_PORT` - Port for pds gatekeeper. Defaults to `8080`
196+197+`GATEKEEPER_CREATE_ACCOUNT_PER_SECOND` - Sets how often it takes a count off the limiter. example if you hit the rate
198+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.
199+200+`GATEKEEPER_CREATE_ACCOUNT_BURST` - Sets how many requests can be made in a burst. In the prior example this is where
201+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
202+off.
203+204+`GATEKEEPER_ALLOW_ONLY_MIGRATIONS` - Defaults false. If set to true, will only allow the
205+`/xrpc/com.atproto.server.createAccount` endpoint to be used for migrations. Meaning it will check for the serviceAuth
206+token and verify it is valid.
207+
+22-21
examples/Caddyfile
···1{
2- email youremail@myemail.com
3- on_demand_tls {
4- ask http://localhost:3000/tls-check
5- }
6}
78*.yourpds.com, yourpds.com {
9- tls {
10- on_demand
11 }
12- # You'll most likely just want from here to....
13- @gatekeeper {
14- path /xrpc/com.atproto.server.getSession
15- path /xrpc/com.atproto.server.updateEmail
16- path /xrpc/com.atproto.server.createSession
17- path /@atproto/oauth-provider/~api/sign-in
18- }
0001920- handle @gatekeeper {
21- #This is the address for PDS gatekeeper, default is 8080
22- reverse_proxy http://localhost:8080
23- }
2425- reverse_proxy http://localhost:3000
26- #..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
27}
28-29-
···1{
2+ email youremail@myemail.com
3+ on_demand_tls {
4+ ask http://localhost:3000/tls-check
5+ }
6}
78*.yourpds.com, yourpds.com {
9+ tls {
10+ on_demand
11 }
12+# You'll most likely just want from here to....
13+ @gatekeeper {
14+ path /xrpc/com.atproto.server.getSession
15+ path /xrpc/com.atproto.server.describeServer
16+ path /xrpc/com.atproto.server.updateEmail
17+ path /xrpc/com.atproto.server.createSession
18+ path /xrpc/com.atproto.server.createAccount
19+ path /@atproto/oauth-provider/~api/sign-in
20+ path /gate/*
21+ }
2223+ handle @gatekeeper {
24+ #This is the address for PDS gatekeeper, default is 8080
25+ reverse_proxy http://localhost:8080
26+ }
2728+ reverse_proxy http://localhost:3000
29+#..here. Copy and paste this replacing the reverse_proxy http://localhost:3000 line
30}
00
+1-1
examples/compose.yml
···39 WATCHTOWER_SCHEDULE: "@midnight"
40 gatekeeper:
41 container_name: gatekeeper
42- image: fatfingers23/pds_gatekeeper:arm-latest
43 network_mode: host
44 restart: unless-stopped
45 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···39 WATCHTOWER_SCHEDULE: "@midnight"
40 gatekeeper:
41 container_name: gatekeeper
42+ image: fatfingers23/pds_gatekeeper:latest
43 network_mode: host
44 restart: unless-stopped
45 #This gives the container to the access to the PDS folder. Source is the location on your server of that directory
···1+-- Add migration script here
2+CREATE TABLE IF NOT EXISTS gate_codes
3+(
4+ code VARCHAR PRIMARY KEY,
5+ handle VARCHAR NOT NULL,
6+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
7+);
8+9+-- Index on created_at for efficient cleanup of expired codes
10+CREATE INDEX IF NOT EXISTS idx_gate_codes_created_at ON gate_codes(created_at);
···1use crate::AppState;
2use crate::helpers::TokenCheckError::InvalidToken;
3use anyhow::anyhow;
4-use axum::body::{Body, to_bytes};
5-use axum::extract::Request;
6-use axum::http::header::CONTENT_TYPE;
7-use axum::http::{HeaderMap, StatusCode, Uri};
8-use axum::response::{IntoResponse, Response};
009use axum_template::TemplateEngine;
10use chrono::Utc;
11-use lettre::message::{MultiPart, SinglePart, header};
12-use lettre::{AsyncTransport, Message};
0000000013use rand::Rng;
14use serde::de::DeserializeOwned;
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use sqlx::SqlitePool;
018use tracing::{error, log};
1920///Used to generate the email 2fa code
···39where
40 T: DeserializeOwned,
41{
42- let uri = format!("{}{}", state.pds_base_url, path);
43 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
4445 let result = state
···134 full_code.push(UPPERCASE_BASE32_CHARS[idx] as char);
135 }
136137- //The PDS implementation creates in lowercase, then converts to uppercase.
138- //Just going a head and doing uppercase here.
139- let slice_one = &full_code[0..5].to_ascii_uppercase();
140- let slice_two = &full_code[5..10].to_ascii_uppercase();
141 format!("{slice_one}-{slice_two}")
142}
143···337338 let email_message = Message::builder()
339 //TODO prob get the proper type in the state
340- .from(state.mailer_from.parse()?)
341 .to(email.parse()?)
342- .subject("Sign in to Bluesky")
343 .multipart(
344 MultiPart::alternative() // This is composed of two parts.
345 .singlepart(
···522523 format!("{masked_local}@{masked_domain}")
524}
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1use crate::AppState;
2use crate::helpers::TokenCheckError::InvalidToken;
3use anyhow::anyhow;
4+use axum::{
5+ body::{Body, to_bytes},
6+ extract::Request,
7+ http::header::CONTENT_TYPE,
8+ http::{HeaderMap, StatusCode, Uri},
9+ response::{IntoResponse, Response},
10+};
11use axum_template::TemplateEngine;
12use chrono::Utc;
13+use jacquard_common::{
14+ service_auth, service_auth::PublicKey, types::did::Did, types::did_doc::VerificationMethod,
15+ types::nsid::Nsid,
16+};
17+use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
18+use josekit::jwe::alg::direct::DirectJweAlgorithm;
19+use lettre::{
20+ AsyncTransport, Message,
21+ message::{MultiPart, SinglePart, header},
22+};
23use rand::Rng;
24use serde::de::DeserializeOwned;
25use serde_json::{Map, Value};
26use sha2::{Digest, Sha256};
27use sqlx::SqlitePool;
28+use std::sync::Arc;
29use tracing::{error, log};
3031///Used to generate the email 2fa code
···50where
51 T: DeserializeOwned,
52{
53+ let uri = format!("{}{}", state.app_config.pds_base_url, path);
54 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?;
5556 let result = state
···145 full_code.push(UPPERCASE_BASE32_CHARS[idx] as char);
146 }
147148+ let slice_one = &full_code[0..5];
149+ let slice_two = &full_code[5..10];
00150 format!("{slice_one}-{slice_two}")
151}
152···346347 let email_message = Message::builder()
348 //TODO prob get the proper type in the state
349+ .from(state.app_config.mailer_from.parse()?)
350 .to(email.parse()?)
351+ .subject(&state.app_config.email_subject)
352 .multipart(
353 MultiPart::alternative() // This is composed of two parts.
354 .singlepart(
···531532 format!("{masked_local}@{masked_domain}")
533}
534+535+pub enum VerifyServiceAuthError {
536+ AuthFailed,
537+ Error(anyhow::Error),
538+}
539+540+/// Verifies the service auth token that is appended to an XRPC proxy request
541+pub async fn verify_service_auth(
542+ jwt: &str,
543+ lxm: &Nsid<'static>,
544+ public_resolver: Arc<PublicResolver>,
545+ service_did: &Did<'static>,
546+ //The did of the user wanting to create an account
547+ requested_did: &Did<'static>,
548+) -> Result<(), VerifyServiceAuthError> {
549+ let parsed =
550+ service_auth::parse_jwt(jwt).map_err(|e| VerifyServiceAuthError::Error(e.into()))?;
551+552+ let claims = parsed.claims();
553+554+ let did_doc = public_resolver
555+ .resolve_did_doc(&requested_did)
556+ .await
557+ .map_err(|err| {
558+ log::error!("Error resolving the service auth for: {}", claims.iss);
559+ return VerifyServiceAuthError::Error(err.into());
560+ })?;
561+562+ // Parse the DID document response to get verification methods
563+ let doc = did_doc.parse().map_err(|err| {
564+ log::error!("Error parsing the service auth did doc: {}", claims.iss);
565+ VerifyServiceAuthError::Error(anyhow::anyhow!(err))
566+ })?;
567+568+ let verification_methods = doc.verification_method.as_deref().ok_or_else(|| {
569+ VerifyServiceAuthError::Error(anyhow::anyhow!(
570+ "No verification methods in did doc: {}",
571+ &claims.iss
572+ ))
573+ })?;
574+575+ let signing_key = extract_signing_key(verification_methods).ok_or_else(|| {
576+ VerifyServiceAuthError::Error(anyhow::anyhow!(
577+ "No signing key found in did doc: {}",
578+ &claims.iss
579+ ))
580+ })?;
581+582+ service_auth::verify_signature(&parsed, &signing_key).map_err(|err| {
583+ log::error!("Error verifying service auth signature: {}", err);
584+ VerifyServiceAuthError::AuthFailed
585+ })?;
586+587+ // Now validate claims (audience, expiration, etc.)
588+ claims.validate(service_did).map_err(|e| {
589+ log::error!("Error validating service auth claims: {}", e);
590+ VerifyServiceAuthError::AuthFailed
591+ })?;
592+593+ if claims.aud != *service_did {
594+ log::error!("Invalid audience (did:web): {}", claims.aud);
595+ return Err(VerifyServiceAuthError::AuthFailed);
596+ }
597+598+ let lxm_from_claims = claims.lxm.as_ref().ok_or_else(|| {
599+ VerifyServiceAuthError::Error(anyhow::anyhow!("No lxm claim in service auth JWT"))
600+ })?;
601+602+ if lxm_from_claims != lxm {
603+ return Err(VerifyServiceAuthError::Error(anyhow::anyhow!(
604+ "Invalid XRPC endpoint requested"
605+ )));
606+ }
607+ Ok(())
608+}
609+610+/// Ripped from Jacquard
611+///
612+/// Extract the signing key from a DID document's verification methods.
613+///
614+/// This looks for a key with type "atproto" or the first available key
615+/// if no atproto-specific key is found.
616+fn extract_signing_key(methods: &[VerificationMethod]) -> Option<PublicKey> {
617+ // First try to find an atproto-specific key
618+ let atproto_method = methods
619+ .iter()
620+ .find(|m| m.r#type.as_ref() == "Multikey" || m.r#type.as_ref() == "atproto");
621+622+ let method = atproto_method.or_else(|| methods.first())?;
623+624+ // Parse the multikey
625+ let public_key_multibase = method.public_key_multibase.as_ref()?;
626+627+ // Decode multibase
628+ let (_, key_bytes) = multibase::decode(public_key_multibase.as_ref()).ok()?;
629+630+ // First two bytes are the multicodec prefix
631+ if key_bytes.len() < 2 {
632+ return None;
633+ }
634+635+ let codec = &key_bytes[..2];
636+ let key_material = &key_bytes[2..];
637+638+ match codec {
639+ // p256-pub (0x1200)
640+ [0x80, 0x24] => PublicKey::from_p256_bytes(key_material).ok(),
641+ // secp256k1-pub (0xe7)
642+ [0xe7, 0x01] => PublicKey::from_k256_bytes(key_material).ok(),
643+ _ => None,
644+ }
645+}
646+647+/// Payload for gate JWE tokens
648+#[derive(serde::Serialize, serde::Deserialize, Debug)]
649+pub struct GateTokenPayload {
650+ pub handle: String,
651+ pub created_at: String,
652+}
653+654+/// Generate a secure JWE token for gate verification
655+pub fn generate_gate_token(handle: &str, encryption_key: &[u8]) -> Result<String, anyhow::Error> {
656+ use josekit::jwe::{JweHeader, alg::direct::DirectJweAlgorithm};
657+658+ let payload = GateTokenPayload {
659+ handle: handle.to_string(),
660+ created_at: Utc::now().to_rfc3339(),
661+ };
662+663+ let payload_json = serde_json::to_string(&payload)?;
664+665+ let mut header = JweHeader::new();
666+ header.set_token_type("JWT");
667+ header.set_content_encryption("A128CBC-HS256");
668+669+ let encrypter = DirectJweAlgorithm::Dir.encrypter_from_bytes(encryption_key)?;
670+671+ // Encrypt
672+ let jwe = josekit::jwe::serialize_compact(payload_json.as_bytes(), &header, &encrypter)?;
673+674+ Ok(jwe)
675+}
676+677+/// Verify and decrypt a gate JWE token, returning the payload if valid
678+pub fn verify_gate_token(
679+ token: &str,
680+ encryption_key: &[u8],
681+) -> Result<GateTokenPayload, anyhow::Error> {
682+ let decrypter = DirectJweAlgorithm::Dir.decrypter_from_bytes(encryption_key)?;
683+ let (payload_bytes, _header) = josekit::jwe::deserialize_compact(token, &decrypter)?;
684+ let payload: GateTokenPayload = serde_json::from_slice(&payload_bytes)?;
685+686+ Ok(payload)
687+}
+207-34
src/main.rs
···1#![warn(clippy::unwrap_used)]
02use crate::oauth_provider::sign_in;
3-use crate::xrpc::com_atproto_server::{create_session, get_session, update_email};
4-use axum::body::Body;
5-use axum::handler::Handler;
6-use axum::http::{Method, header};
7-use axum::middleware as ax_middleware;
8-use axum::routing::post;
9-use axum::{Router, routing::get};
0000010use axum_template::engine::Engine;
11use handlebars::Handlebars;
12-use hyper_util::client::legacy::connect::HttpConnector;
13-use hyper_util::rt::TokioExecutor;
014use lettre::{AsyncSmtpTransport, Tokio1Executor};
015use rust_embed::RustEmbed;
16use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
17use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
18use std::path::Path;
019use std::time::Duration;
20use std::{env, net::SocketAddr};
21-use tower_governor::GovernorLayer;
22-use tower_governor::governor::GovernorConfigBuilder;
23-use tower_http::compression::CompressionLayer;
24-use tower_http::cors::{Any, CorsLayer};
00025use tracing::log;
26use tracing_subscriber::{EnvFilter, fmt, prelude::*};
27028pub mod helpers;
29mod middleware;
30mod oauth_provider;
···37#[include = "*.hbs"]
38struct EmailTemplates;
390000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040#[derive(Clone)]
41pub struct AppState {
42 account_pool: SqlitePool,
43 pds_gatekeeper_pool: SqlitePool,
44 reverse_proxy_client: HyperUtilClient,
45- pds_base_url: String,
46 mailer: AsyncSmtpTransport<Tokio1Executor>,
47- mailer_from: String,
48 template_engine: Engine<Handlebars<'static>>,
0049}
5051async fn root_handler() -> impl axum::response::IntoResponse {
···91 let pds_env_location =
92 env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string());
9394- dotenvy::from_path(Path::new(&pds_env_location))?;
95- let pds_root = env::var("PDS_DATA_DIRECTORY")?;
000000096 let account_db_url = format!("{pds_root}/account.sqlite");
9798 let account_options = SqliteConnectOptions::new()
···129 //Emailer set up
130 let smtp_url =
131 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
132- let sent_from = env::var("PDS_EMAIL_FROM_ADDRESS")
133- .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
134135 let mailer: AsyncSmtpTransport<Tokio1Executor> =
136 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
···147 let _ = hbs.register_embed_templates::<EmailTemplates>();
148 }
149150- let pds_base_url =
151- env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
00000000152153 let state = AppState {
154 account_pool,
155 pds_gatekeeper_pool,
156 reverse_proxy_client: client,
157- pds_base_url,
158 mailer,
159- mailer_from: sent_from,
160 template_engine: Engine::from(hbs),
00161 };
162163 // Rate limiting
164 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
165- let create_session_governor_conf = GovernorConfigBuilder::default()
166 .per_second(60)
167 .burst_size(5)
0168 .finish()
169- .expect("failed to create governor config. this should not happen and is a bug");
170171 // Create a second config with the same settings for the other endpoint
172 let sign_in_governor_conf = GovernorConfigBuilder::default()
173 .per_second(60)
174 .burst_size(5)
0175 .finish()
176- .expect("failed to create governor config. this should not happen and is a bug");
00177178- let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
00000000000000000000000000000179 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
0000180 let interval = Duration::from_secs(60);
181 // a separate background task to clean up
182 std::thread::spawn(move || {
183 loop {
184 std::thread::sleep(interval);
185- create_session_governor_limiter.retain_recent();
186 sign_in_governor_limiter.retain_recent();
0187 }
188 });
189···192 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
193 .allow_headers(Any);
194195- let app = Router::new()
196 .route("/", get(root_handler))
0197 .route(
198- "/xrpc/com.atproto.server.getSession",
199- get(get_session).layer(ax_middleware::from_fn(middleware::extract_did)),
200 )
201 .route(
202 "/xrpc/com.atproto.server.updateEmail",
···204 )
205 .route(
206 "/@atproto/oauth-provider/~api/sign-in",
207- post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
208 )
209 .route(
210 "/xrpc/com.atproto.server.createSession",
211- post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
212 )
0000000000000213 .layer(CompressionLayer::new())
214 .layer(cors)
215 .with_state(state);
216217- let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
218 let port: u16 = env::var("GATEKEEPER_PORT")
219 .ok()
220 .and_then(|s| s.parse().ok())
···1#![warn(clippy::unwrap_used)]
2+use crate::gate::{get_gate, post_gate};
3use crate::oauth_provider::sign_in;
4+use crate::xrpc::com_atproto_server::{
5+ create_account, create_session, describe_server, get_session, update_email,
6+};
7+use axum::{
8+ Router,
9+ body::Body,
10+ handler::Handler,
11+ http::{Method, header},
12+ middleware as ax_middleware,
13+ routing::get,
14+ routing::post,
15+};
16use axum_template::engine::Engine;
17use handlebars::Handlebars;
18+use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
19+use jacquard_common::types::did::Did;
20+use jacquard_identity::{PublicResolver, resolver::PlcSource};
21use lettre::{AsyncSmtpTransport, Tokio1Executor};
22+use rand::Rng;
23use rust_embed::RustEmbed;
24use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
25use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
26use std::path::Path;
27+use std::sync::Arc;
28use std::time::Duration;
29use std::{env, net::SocketAddr};
30+use tower_governor::{
31+ GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
32+};
33+use tower_http::{
34+ compression::CompressionLayer,
35+ cors::{Any, CorsLayer},
36+};
37use tracing::log;
38use tracing_subscriber::{EnvFilter, fmt, prelude::*};
3940+mod gate;
41pub mod helpers;
42mod middleware;
43mod oauth_provider;
···50#[include = "*.hbs"]
51struct EmailTemplates;
5253+#[derive(RustEmbed)]
54+#[folder = "html_templates"]
55+#[include = "*.hbs"]
56+struct HtmlTemplates;
57+58+/// Mostly the env variables that are used in the app
59+#[derive(Clone, Debug)]
60+pub struct AppConfig {
61+ pds_base_url: String,
62+ mailer_from: String,
63+ email_subject: String,
64+ allow_only_migrations: bool,
65+ use_captcha: bool,
66+ //The url to redirect to after a successful captcha. Defaults to https://bsky.app, but you may have another social-app fork you rather your users use
67+ //that need to capture this redirect url for creating an account
68+ default_successful_redirect_url: String,
69+ pds_service_did: Did<'static>,
70+ gate_jwe_key: Vec<u8>,
71+ captcha_success_redirects: Vec<String>,
72+}
73+74+impl AppConfig {
75+ pub fn new() -> Self {
76+ let pds_base_url =
77+ env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
78+ let mailer_from = env::var("PDS_EMAIL_FROM_ADDRESS")
79+ .expect("PDS_EMAIL_FROM_ADDRESS is not set in your pds.env file");
80+ //Hack not my favorite, but it does work
81+ let allow_only_migrations = env::var("GATEKEEPER_ALLOW_ONLY_MIGRATIONS")
82+ .map(|val| val.parse::<bool>().unwrap_or(false))
83+ .unwrap_or(false);
84+85+ let use_captcha = env::var("GATEKEEPER_CREATE_ACCOUNT_CAPTCHA")
86+ .map(|val| val.parse::<bool>().unwrap_or(false))
87+ .unwrap_or(false);
88+89+ // PDS_SERVICE_DID is the did:web if set, if not it's PDS_HOSTNAME
90+ let pds_service_did =
91+ env::var("PDS_SERVICE_DID").unwrap_or_else(|_| match env::var("PDS_HOSTNAME") {
92+ Ok(pds_hostname) => format!("did:web:{}", pds_hostname),
93+ Err(_) => {
94+ panic!("PDS_HOSTNAME or PDS_SERVICE_DID must be set in your pds.env file")
95+ }
96+ });
97+98+ let email_subject = env::var("GATEKEEPER_TWO_FACTOR_EMAIL_SUBJECT")
99+ .unwrap_or("Sign in to Bluesky".to_string());
100+101+ // Load or generate JWE encryption key (32 bytes for AES-256)
102+ let gate_jwe_key = env::var("GATEKEEPER_JWE_KEY")
103+ .ok()
104+ .and_then(|key_hex| hex::decode(key_hex).ok())
105+ .unwrap_or_else(|| {
106+ // Generate a random 32-byte key if not provided
107+ let key: Vec<u8> = (0..32).map(|_| rand::rng().random()).collect();
108+ log::warn!("WARNING: No GATEKEEPER_JWE_KEY found in the environment. Generated random key (hex): {}", hex::encode(&key));
109+ log::warn!("This is not strictly needed unless you scale PDS Gatekeeper. Will not also be able to verify tokens between reboots, but they are short lived (5mins).");
110+ key
111+ });
112+113+ if gate_jwe_key.len() != 32 {
114+ panic!(
115+ "GATEKEEPER_JWE_KEY must be 32 bytes (64 hex characters) for AES-256 encryption"
116+ );
117+ }
118+119+ let captcha_success_redirects = match env::var("GATEKEEPER_CAPTCHA_SUCCESS_REDIRECTS") {
120+ Ok(from_env) => from_env.split(",").map(|s| s.trim().to_string()).collect(),
121+ Err(_) => {
122+ vec![
123+ String::from("https://bsky.app"),
124+ String::from("https://pdsmoover.com"),
125+ String::from("https://blacksky.community"),
126+ String::from("https://tektite.cc"),
127+ ]
128+ }
129+ };
130+131+ AppConfig {
132+ pds_base_url,
133+ mailer_from,
134+ email_subject,
135+ allow_only_migrations,
136+ use_captcha,
137+ default_successful_redirect_url: env::var("GATEKEEPER_DEFAULT_CAPTCHA_REDIRECT")
138+ .unwrap_or("https://bsky.app".to_string()),
139+ pds_service_did: pds_service_did
140+ .parse()
141+ .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"),
142+ gate_jwe_key,
143+ captcha_success_redirects,
144+ }
145+ }
146+}
147+148#[derive(Clone)]
149pub struct AppState {
150 account_pool: SqlitePool,
151 pds_gatekeeper_pool: SqlitePool,
152 reverse_proxy_client: HyperUtilClient,
0153 mailer: AsyncSmtpTransport<Tokio1Executor>,
0154 template_engine: Engine<Handlebars<'static>>,
155+ resolver: Arc<PublicResolver>,
156+ app_config: AppConfig,
157}
158159async fn root_handler() -> impl axum::response::IntoResponse {
···199 let pds_env_location =
200 env::var("PDS_ENV_LOCATION").unwrap_or_else(|_| "/pds/pds.env".to_string());
201202+ let result_of_finding_pds_env = dotenvy::from_path(Path::new(&pds_env_location));
203+ if let Err(e) = result_of_finding_pds_env {
204+ log::error!(
205+ "Error loading pds.env file (ignore if you loaded your variables in the environment somehow else): {e}"
206+ );
207+ }
208+209+ let pds_root =
210+ env::var("PDS_DATA_DIRECTORY").expect("PDS_DATA_DIRECTORY is not set in your pds.env file");
211 let account_db_url = format!("{pds_root}/account.sqlite");
212213 let account_options = SqliteConnectOptions::new()
···244 //Emailer set up
245 let smtp_url =
246 env::var("PDS_EMAIL_SMTP_URL").expect("PDS_EMAIL_SMTP_URL is not set in your pds.env file");
00247248 let mailer: AsyncSmtpTransport<Tokio1Executor> =
249 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
···260 let _ = hbs.register_embed_templates::<EmailTemplates>();
261 }
262263+ let _ = hbs.register_embed_templates::<HtmlTemplates>();
264+265+ //Reads the PLC source from the pds env's or defaults to ol faithful
266+ let plc_source_url =
267+ env::var("PDS_DID_PLC_URL").unwrap_or_else(|_| "https://plc.directory".to_string());
268+ let plc_source = PlcSource::PlcDirectory {
269+ base: plc_source_url.parse().unwrap(),
270+ };
271+ let mut resolver = PublicResolver::default();
272+ resolver = resolver.with_plc_source(plc_source.clone());
273274 let state = AppState {
275 account_pool,
276 pds_gatekeeper_pool,
277 reverse_proxy_client: client,
0278 mailer,
0279 template_engine: Engine::from(hbs),
280+ resolver: Arc::new(resolver),
281+ app_config: AppConfig::new(),
282 };
283284 // Rate limiting
285 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
286+ let captcha_governor_conf = GovernorConfigBuilder::default()
287 .per_second(60)
288 .burst_size(5)
289+ .key_extractor(SmartIpKeyExtractor)
290 .finish()
291+ .expect("failed to create governor config for create session. this should not happen and is a bug");
292293 // Create a second config with the same settings for the other endpoint
294 let sign_in_governor_conf = GovernorConfigBuilder::default()
295 .per_second(60)
296 .burst_size(5)
297+ .key_extractor(SmartIpKeyExtractor)
298 .finish()
299+ .expect(
300+ "failed to create governor config for sign in. this should not happen and is a bug",
301+ );
302303+ let create_account_limiter_time: Option<String> =
304+ env::var("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND").ok();
305+ let create_account_limiter_burst: Option<String> =
306+ env::var("GATEKEEPER_CREATE_ACCOUNT_BURST").ok();
307+308+ //Default should be 608 requests per 5 minutes, PDS is 300 per 500 so will never hit it ideally
309+ let mut create_account_governor_conf = GovernorConfigBuilder::default();
310+ if create_account_limiter_time.is_some() {
311+ let time = create_account_limiter_time
312+ .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND not set")
313+ .parse::<u64>()
314+ .expect("GATEKEEPER_CREATE_ACCOUNT_PER_SECOND must be a valid integer");
315+ create_account_governor_conf.per_second(time);
316+ }
317+318+ if create_account_limiter_burst.is_some() {
319+ let burst = create_account_limiter_burst
320+ .expect("GATEKEEPER_CREATE_ACCOUNT_BURST not set")
321+ .parse::<u32>()
322+ .expect("GATEKEEPER_CREATE_ACCOUNT_BURST must be a valid integer");
323+ create_account_governor_conf.burst_size(burst);
324+ }
325+326+ let create_account_governor_conf = create_account_governor_conf
327+ .key_extractor(SmartIpKeyExtractor)
328+ .finish().expect(
329+ "failed to create governor config for create account. this should not happen and is a bug",
330+ );
331+332+ let captcha_governor_limiter = captcha_governor_conf.limiter().clone();
333 let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
334+ let create_account_governor_limiter = create_account_governor_conf.limiter().clone();
335+336+ let sign_in_governor_layer = GovernorLayer::new(sign_in_governor_conf);
337+338 let interval = Duration::from_secs(60);
339 // a separate background task to clean up
340 std::thread::spawn(move || {
341 loop {
342 std::thread::sleep(interval);
343+ captcha_governor_limiter.retain_recent();
344 sign_in_governor_limiter.retain_recent();
345+ create_account_governor_limiter.retain_recent();
346 }
347 });
348···351 .allow_methods([Method::GET, Method::OPTIONS, Method::POST])
352 .allow_headers(Any);
353354+ let mut app = Router::new()
355 .route("/", get(root_handler))
356+ .route("/xrpc/com.atproto.server.getSession", get(get_session))
357 .route(
358+ "/xrpc/com.atproto.server.describeServer",
359+ get(describe_server),
360 )
361 .route(
362 "/xrpc/com.atproto.server.updateEmail",
···364 )
365 .route(
366 "/@atproto/oauth-provider/~api/sign-in",
367+ post(sign_in).layer(sign_in_governor_layer.clone()),
368 )
369 .route(
370 "/xrpc/com.atproto.server.createSession",
371+ post(create_session.layer(sign_in_governor_layer)),
372 )
373+ .route(
374+ "/xrpc/com.atproto.server.createAccount",
375+ post(create_account).layer(GovernorLayer::new(create_account_governor_conf)),
376+ );
377+378+ if state.app_config.use_captcha {
379+ app = app.route(
380+ "/gate/signup",
381+ get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))),
382+ );
383+ }
384+385+ let app = app
386 .layer(CompressionLayer::new())
387 .layer(cors)
388 .with_state(state);
389390+ let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
391 let port: u16 = env::var("GATEKEEPER_PORT")
392 .ok()
393 .and_then(|s| s.parse().ok())
+73-39
src/middleware.rs
···12#[derive(Clone, Debug)]
13pub struct Did(pub Option<String>);
1400000015#[derive(Serialize, Deserialize)]
16pub struct TokenClaims {
17 pub sub: String,
18}
1920pub async fn extract_did(mut req: Request, next: Next) -> impl IntoResponse {
21- let token = extract_bearer(req.headers());
2223- match token {
24- Ok(token) => {
25- match token {
26 None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
27 .expect("Error creating an error response"),
28- Some(token) => {
29- let token = UntrustedToken::new(&token);
30- if token.is_err() {
31- return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
32- .expect("Error creating an error response");
33- }
34- let parsed_token = token.expect("Already checked for error");
35- let claims: Result<Claims<TokenClaims>, ValidationError> =
36- parsed_token.deserialize_claims_unchecked();
37- if claims.is_err() {
38- return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
39- .expect("Error creating an error response");
40- }
0000000000004142- let key = Hs256Key::new(
43- env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"),
44- );
45- let token: Result<Token<TokenClaims>, ValidationError> =
46- Hs256.validator(&key).validate(&parsed_token);
47- if token.is_err() {
48- return json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
49- .expect("Error creating an error response");
0000000000000050 }
51- let token = token.expect("Already checked for error,");
52- //Not going to worry about expiration since it still goes to the PDS
53- req.extensions_mut()
54- .insert(Did(Some(token.claims().custom.sub.clone())));
55 next.run(req).await
56 }
57 }
···64 }
65}
6667-fn extract_bearer(headers: &HeaderMap) -> Result<Option<String>, String> {
68 match headers.get(axum::http::header::AUTHORIZATION) {
69 None => Ok(None),
70- Some(hv) => match hv.to_str() {
71- Err(_) => Err("Authorization header is not valid".into()),
72- Ok(s) => {
73- // Accept forms like: "Bearer <token>" (case-sensitive for the scheme here)
74- let mut parts = s.splitn(2, ' ');
75- match (parts.next(), parts.next()) {
76- (Some("Bearer"), Some(tok)) if !tok.is_empty() => Ok(Some(tok.to_string())),
77- _ => Err("Authorization header must be in format 'Bearer <token>'".into()),
0000078 }
79 }
80- },
81 }
82}
···12#[derive(Clone, Debug)]
13pub struct Did(pub Option<String>);
1415+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16+pub enum AuthScheme {
17+ Bearer,
18+ DPoP,
19+}
20+21#[derive(Serialize, Deserialize)]
22pub struct TokenClaims {
23 pub sub: String,
24}
2526pub async fn extract_did(mut req: Request, next: Next) -> impl IntoResponse {
27+ let auth = extract_auth(req.headers());
2829+ match auth {
30+ Ok(auth_opt) => {
31+ match auth_opt {
32 None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
33 .expect("Error creating an error response"),
34+ Some((scheme, token_str)) => {
35+ // For Bearer, validate JWT and extract DID from `sub`.
36+ // For DPoP, we currently only pass through and do not validate here; insert None DID.
37+ match scheme {
38+ AuthScheme::Bearer => {
39+ let token = UntrustedToken::new(&token_str);
40+ if token.is_err() {
41+ return json_error_response(
42+ StatusCode::BAD_REQUEST,
43+ "TokenRequired",
44+ "",
45+ )
46+ .expect("Error creating an error response");
47+ }
48+ let parsed_token = token.expect("Already checked for error");
49+ let claims: Result<Claims<TokenClaims>, ValidationError> =
50+ parsed_token.deserialize_claims_unchecked();
51+ if claims.is_err() {
52+ return json_error_response(
53+ StatusCode::BAD_REQUEST,
54+ "TokenRequired",
55+ "",
56+ )
57+ .expect("Error creating an error response");
58+ }
5960+ let key = Hs256Key::new(
61+ env::var("PDS_JWT_SECRET")
62+ .expect("PDS_JWT_SECRET not set in the pds.env"),
63+ );
64+ let token: Result<Token<TokenClaims>, ValidationError> =
65+ Hs256.validator(&key).validate(&parsed_token);
66+ if token.is_err() {
67+ return json_error_response(
68+ StatusCode::BAD_REQUEST,
69+ "InvalidToken",
70+ "",
71+ )
72+ .expect("Error creating an error response");
73+ }
74+ let token = token.expect("Already checked for error,");
75+ req.extensions_mut()
76+ .insert(Did(Some(token.claims().custom.sub.clone())));
77+ }
78+ AuthScheme::DPoP => {
79+ //Not going to worry about oauth email update for now, just always forward to the PDS
80+ req.extensions_mut().insert(Did(None));
81+ }
82 }
83+00084 next.run(req).await
85 }
86 }
···93 }
94}
9596+fn extract_auth(headers: &HeaderMap) -> Result<Option<(AuthScheme, String)>, String> {
97 match headers.get(axum::http::header::AUTHORIZATION) {
98 None => Ok(None),
99+ Some(hv) => {
100+ match hv.to_str() {
101+ Err(_) => Err("Authorization header is not valid".into()),
102+ Ok(s) => {
103+ // Accept forms like: "Bearer <token>" or "DPoP <token>" (case-sensitive for the scheme here)
104+ let mut parts = s.splitn(2, ' ');
105+ match (parts.next(), parts.next()) {
106+ (Some("Bearer"), Some(tok)) if !tok.is_empty() =>
107+ Ok(Some((AuthScheme::Bearer, tok.to_string()))),
108+ (Some("DPoP"), Some(tok)) if !tok.is_empty() =>
109+ Ok(Some((AuthScheme::DPoP, tok.to_string()))),
110+ _ => Err("Authorization header must be in format 'Bearer <token>' or 'DPoP <token>'".into()),
111+ }
112 }
113 }
114+ }
115 }
116}