Microservice to bring 2FA to self hosted PDSes
1use crate::AppState;
2use crate::helpers::{
3 AuthResult, ProxiedResult, TokenCheckError, VerifyServiceAuthError, json_error_response,
4 preauth_check, proxy_get_json, proxy_post, verify_gate_token, verify_service_auth,
5};
6use crate::middleware::Did;
7use axum::body::{Body, to_bytes};
8use axum::extract::State;
9use axum::http::{HeaderMap, StatusCode, header};
10use axum::response::{IntoResponse, Response};
11use axum::{Extension, Json, debug_handler, extract, extract::Request};
12use chrono::{Duration, Utc};
13use jacquard_common::types::did::Did as JacquardDid;
14use serde::{Deserialize, Serialize};
15use serde_json;
16use tracing::log;
17
18#[derive(Serialize, Deserialize, Debug, Clone)]
19#[serde(rename_all = "camelCase")]
20enum AccountStatus {
21 Takendown,
22 Suspended,
23 Deactivated,
24}
25
26#[derive(Serialize, Deserialize, Debug, Clone)]
27#[serde(rename_all = "camelCase")]
28struct GetSessionResponse {
29 handle: String,
30 did: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 email: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 email_confirmed: Option<bool>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 email_auth_factor: Option<bool>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 did_doc: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 active: Option<bool>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 status: Option<AccountStatus>,
43}
44
45#[derive(Serialize, Deserialize, Debug, Clone)]
46#[serde(rename_all = "camelCase")]
47pub struct UpdateEmailResponse {
48 email: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 email_auth_factor: Option<bool>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 token: Option<String>,
53}
54
55#[allow(dead_code)]
56#[derive(Deserialize, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct CreateSessionRequest {
59 identifier: String,
60 password: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 auth_factor_token: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 allow_takendown: Option<bool>,
65}
66
67#[derive(Deserialize, Serialize, Debug)]
68#[serde(rename_all = "camelCase")]
69pub struct CreateAccountRequest {
70 handle: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 email: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 password: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 did: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 invite_code: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 verification_code: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 plc_op: Option<serde_json::Value>,
83}
84
85#[derive(Deserialize, Serialize, Debug, Clone)]
86#[serde(rename_all = "camelCase")]
87pub struct DescribeServerContact {
88 #[serde(skip_serializing_if = "Option::is_none")]
89 email: Option<String>,
90}
91
92#[derive(Deserialize, Serialize, Debug, Clone)]
93#[serde(rename_all = "camelCase")]
94pub struct DescribeServerLinks {
95 #[serde(skip_serializing_if = "Option::is_none")]
96 privacy_policy: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 terms_of_service: Option<String>,
99}
100
101#[derive(Deserialize, Serialize, Debug, Clone)]
102#[serde(rename_all = "camelCase")]
103pub struct DescribeServerResponse {
104 #[serde(skip_serializing_if = "Option::is_none")]
105 invite_code_required: Option<bool>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 phone_verification_required: Option<bool>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 available_user_domains: Option<Vec<String>>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 links: Option<DescribeServerLinks>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 contact: Option<DescribeServerContact>,
114 #[serde(skip_serializing_if = "Option::is_none")]
115 did: Option<String>,
116}
117
118pub async fn create_session(
119 State(state): State<AppState>,
120 headers: HeaderMap,
121 Json(payload): extract::Json<CreateSessionRequest>,
122) -> Result<Response<Body>, StatusCode> {
123 let identifier = payload.identifier.clone();
124 let password = payload.password.clone();
125 let auth_factor_token = payload.auth_factor_token.clone();
126
127 // Run the shared pre-auth logic to validate and check 2FA requirement
128 match preauth_check(&state, &identifier, &password, auth_factor_token, false).await {
129 Ok(result) => match result {
130 AuthResult::WrongIdentityOrPassword => json_error_response(
131 StatusCode::UNAUTHORIZED,
132 "AuthenticationRequired",
133 "Invalid identifier or password",
134 ),
135 AuthResult::TwoFactorRequired(_) => {
136 // Email sending step can be handled here if needed in the future.
137 json_error_response(
138 StatusCode::UNAUTHORIZED,
139 "AuthFactorTokenRequired",
140 "A sign in code has been sent to your email address",
141 )
142 }
143 AuthResult::ProxyThrough => {
144 //No 2FA or already passed
145 let payload_bytes =
146 serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
147 proxy_post(&state, &headers, "/xrpc/com.atproto.server.createSession", payload_bytes).await
148 }
149 AuthResult::TokenCheckFailed(err) => match err {
150 TokenCheckError::InvalidToken => {
151 json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "Token is invalid")
152 }
153 TokenCheckError::ExpiredToken => {
154 json_error_response(StatusCode::BAD_REQUEST, "ExpiredToken", "Token is expired")
155 }
156 },
157 },
158 Err(err) => {
159 log::error!(
160 "Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}"
161 );
162 json_error_response(
163 StatusCode::INTERNAL_SERVER_ERROR,
164 "InternalServerError",
165 "This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.",
166 )
167 }
168 }
169}
170
171#[debug_handler]
172pub async fn update_email(
173 State(state): State<AppState>,
174 Extension(did): Extension<Did>,
175 headers: HeaderMap,
176 Json(payload): extract::Json<UpdateEmailResponse>,
177) -> Result<Response<Body>, StatusCode> {
178 //If email auth is not set at all it is a update email address
179 let email_auth_not_set = payload.email_auth_factor.is_none();
180 //If email auth is set it is to either turn on or off 2fa
181 let email_auth_update = payload.email_auth_factor.unwrap_or(false);
182
183 //This means the middleware successfully extracted a did from the request, if not it just needs to be forward to the PDS
184 //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
185 let did_is_not_empty = did.0.is_some();
186
187 if did_is_not_empty {
188 // Email update asked for
189 if email_auth_update {
190 let email = payload.email.clone();
191 let email_confirmed = match sqlx::query_as::<_, (String,)>(
192 "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?",
193 )
194 .bind(&email)
195 .fetch_optional(&state.account_pool)
196 .await
197 {
198 Ok(row) => row,
199 Err(err) => {
200 log::error!("Error checking if email is confirmed: {err}");
201 return Err(StatusCode::BAD_REQUEST);
202 }
203 };
204
205 //Since the email is already confirmed we can enable 2fa
206 return match email_confirmed {
207 None => Err(StatusCode::BAD_REQUEST),
208 Some(did_row) => {
209 let _ = sqlx::query(
210 "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1",
211 )
212 .bind(&did_row.0)
213 .execute(&state.pds_gatekeeper_pool)
214 .await
215 .map_err(|err| {
216 log::error!("Error enabling 2FA: {err}");
217 StatusCode::BAD_REQUEST
218 })?;
219
220 Ok(StatusCode::OK.into_response())
221 }
222 };
223 }
224
225 // User wants auth turned off
226 if !email_auth_update && !email_auth_not_set {
227 //User wants auth turned off and has a token
228 if let Some(token) = &payload.token {
229 let token_found = match sqlx::query_as::<_, (String,)>(
230 "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'",
231 )
232 .bind(token)
233 .bind(&did.0)
234 .fetch_optional(&state.account_pool)
235 .await{
236 Ok(token) => token,
237 Err(err) => {
238 log::error!("Error checking if token is valid: {err}");
239 return Err(StatusCode::BAD_REQUEST);
240 }
241 };
242
243 return if token_found.is_some() {
244 //TODO I think there may be a bug here and need to do some retry logic
245 // First try was erroring, seconds was allowing
246 match sqlx::query(
247 "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0",
248 )
249 .bind(&did.0)
250 .execute(&state.pds_gatekeeper_pool)
251 .await {
252 Ok(_) => {}
253 Err(err) => {
254 log::error!("Error updating email auth: {err}");
255 return Err(StatusCode::BAD_REQUEST);
256 }
257 }
258
259 Ok(StatusCode::OK.into_response())
260 } else {
261 Err(StatusCode::BAD_REQUEST)
262 };
263 }
264 }
265 }
266 // Updating the actual email address by sending it on to the PDS
267 let payload_bytes = serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
268 proxy_post(&state, &headers, "/xrpc/com.atproto.server.updateEmail", payload_bytes).await
269}
270
271pub async fn get_session(
272 State(state): State<AppState>,
273 req: Request,
274) -> Result<Response<Body>, StatusCode> {
275 match proxy_get_json::<GetSessionResponse>(&state, req, "/xrpc/com.atproto.server.getSession")
276 .await?
277 {
278 ProxiedResult::Parsed {
279 value: mut session, ..
280 } => {
281 let did = session.did.clone();
282 let required_opt = sqlx::query_as::<_, (u8,)>(
283 "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1",
284 )
285 .bind(&did)
286 .fetch_optional(&state.pds_gatekeeper_pool)
287 .await
288 .map_err(|_| StatusCode::BAD_REQUEST)?;
289
290 let email_auth_factor = match required_opt {
291 Some(row) => row.0 != 0,
292 None => false,
293 };
294
295 session.email_auth_factor = Some(email_auth_factor);
296 Ok(Json(session).into_response())
297 }
298 ProxiedResult::Passthrough(resp) => Ok(resp),
299 }
300}
301
302pub async fn describe_server(
303 State(state): State<AppState>,
304 req: Request,
305) -> Result<Response<Body>, StatusCode> {
306 match proxy_get_json::<DescribeServerResponse>(
307 &state,
308 req,
309 "/xrpc/com.atproto.server.describeServer",
310 )
311 .await?
312 {
313 ProxiedResult::Parsed {
314 value: mut server_info,
315 ..
316 } => {
317 //This signifies the server is configured for captcha verification
318 server_info.phone_verification_required = Some(state.app_config.use_captcha);
319 Ok(Json(server_info).into_response())
320 }
321 ProxiedResult::Passthrough(resp) => Ok(resp),
322 }
323}
324
325/// Verify a gate code matches the handle and is not expired
326async fn verify_gate_code(
327 state: &AppState,
328 code: &str,
329 handle: &str,
330) -> Result<bool, anyhow::Error> {
331 // First, decrypt and verify the JWE token
332 let payload = match verify_gate_token(code, &state.app_config.gate_jwe_key) {
333 Ok(p) => p,
334 Err(e) => {
335 log::warn!("Failed to decrypt gate token: {}", e);
336 return Ok(false);
337 }
338 };
339
340 // Verify the handle matches
341 if payload.handle != handle {
342 log::warn!(
343 "Gate code handle mismatch: expected {}, got {}",
344 handle,
345 payload.handle
346 );
347 return Ok(false);
348 }
349
350 let created_at = chrono::DateTime::parse_from_rfc3339(&payload.created_at)
351 .map_err(|e| anyhow::anyhow!("Failed to parse created_at from token: {}", e))?
352 .with_timezone(&Utc);
353
354 let now = Utc::now();
355 let age = now - created_at;
356
357 // Check if the token is expired (5 minutes)
358 if age > Duration::minutes(5) {
359 log::warn!("Gate code expired for handle {}", handle);
360 return Ok(false);
361 }
362
363 // Verify the token exists in the database (to prevent reuse)
364 let row: Option<(String,)> =
365 sqlx::query_as("SELECT code FROM gate_codes WHERE code = ? and handle = ? LIMIT 1")
366 .bind(code)
367 .bind(handle)
368 .fetch_optional(&state.pds_gatekeeper_pool)
369 .await?;
370
371 if row.is_none() {
372 log::warn!("Gate code not found in database or already used");
373 return Ok(false);
374 }
375
376 // Token is valid, delete it so it can't be reused
377 //TODO probably also delete expired codes? Will need to do that at some point probably altho the where is on code and handle
378
379 sqlx::query("DELETE FROM gate_codes WHERE code = ?")
380 .bind(code)
381 .execute(&state.pds_gatekeeper_pool)
382 .await?;
383
384 Ok(true)
385}
386
387pub async fn create_account(
388 State(state): State<AppState>,
389 req: Request,
390) -> Result<Response<Body>, StatusCode> {
391 let headers = req.headers().clone();
392 let body_bytes = to_bytes(req.into_body(), usize::MAX)
393 .await
394 .map_err(|_| StatusCode::BAD_REQUEST)?;
395
396 // Parse the body to check for verification code
397 let account_request: CreateAccountRequest =
398 serde_json::from_slice(&body_bytes).map_err(|e| {
399 log::error!("Failed to parse create account request: {}", e);
400 StatusCode::BAD_REQUEST
401 })?;
402
403 // Check for service auth (migrations) if configured
404 if state.app_config.allow_only_migrations {
405 // Expect Authorization: Bearer <jwt>
406 let auth_header = headers
407 .get(header::AUTHORIZATION)
408 .and_then(|v| v.to_str().ok())
409 .map(str::to_string);
410
411 let Some(value) = auth_header else {
412 log::error!("No Authorization header found in the request");
413 return json_error_response(
414 StatusCode::UNAUTHORIZED,
415 "InvalidAuth",
416 "This PDS is configured to only allow accounts created by migrations via this endpoint.",
417 );
418 };
419
420 // Ensure Bearer prefix
421 let token = value.strip_prefix("Bearer ").unwrap_or("").trim();
422 if token.is_empty() {
423 log::error!("No Service Auth token found in the Authorization header");
424 return json_error_response(
425 StatusCode::UNAUTHORIZED,
426 "InvalidAuth",
427 "This PDS is configured to only allow accounts created by migrations via this endpoint.",
428 );
429 }
430
431 // Ensure a non-empty DID was provided when migrations are enabled
432 let requested_did_str = match account_request.did.as_deref() {
433 Some(s) if !s.trim().is_empty() => s,
434 _ => {
435 return json_error_response(
436 StatusCode::BAD_REQUEST,
437 "InvalidRequest",
438 "The 'did' field is required when migrations are enforced.",
439 );
440 }
441 };
442
443 // Parse the DID into the expected type for verification
444 let requested_did: JacquardDid<'static> = match requested_did_str.parse() {
445 Ok(d) => d,
446 Err(e) => {
447 log::error!(
448 "Invalid DID format provided in createAccount: {} | error: {}",
449 requested_did_str,
450 e
451 );
452 return json_error_response(
453 StatusCode::BAD_REQUEST,
454 "InvalidRequest",
455 "The 'did' field is not a valid DID.",
456 );
457 }
458 };
459
460 let nsid = "com.atproto.server.createAccount".parse().unwrap();
461 match verify_service_auth(
462 token,
463 &nsid,
464 state.resolver.clone(),
465 &state.app_config.pds_service_did,
466 &requested_did,
467 )
468 .await
469 {
470 //Just do nothing if it passes so it continues.
471 Ok(_) => {}
472 Err(err) => match err {
473 VerifyServiceAuthError::AuthFailed => {
474 return json_error_response(
475 StatusCode::UNAUTHORIZED,
476 "InvalidAuth",
477 "This PDS is configured to only allow accounts created by migrations via this endpoint.",
478 );
479 }
480 VerifyServiceAuthError::Error(err) => {
481 log::error!("Error verifying service auth token: {err}");
482 return json_error_response(
483 StatusCode::BAD_REQUEST,
484 "InvalidRequest",
485 "There has been an error, please contact your PDS administrator for help and for them to review the server logs.",
486 );
487 }
488 },
489 }
490 }
491
492 // Check for captcha verification if configured
493 if state.app_config.use_captcha {
494 if let Some(ref verification_code) = account_request.verification_code {
495 match verify_gate_code(&state, verification_code, &account_request.handle).await {
496 //TODO has a few errors to support
497
498 //expired token
499 // {
500 // "error": "ExpiredToken",
501 // "message": "Token has expired"
502 // }
503
504 //TODO ALSO add rate limits on the /gate endpoints so they can't be abused
505 Ok(true) => {
506 log::info!("Gate code verified for handle: {}", account_request.handle);
507 }
508 Ok(false) => {
509 log::warn!(
510 "Invalid or expired gate code for handle: {}",
511 account_request.handle
512 );
513 return json_error_response(
514 StatusCode::BAD_REQUEST,
515 "InvalidToken",
516 "Token could not be verified",
517 );
518 }
519 Err(e) => {
520 log::error!("Error verifying gate code: {}", e);
521 return json_error_response(
522 StatusCode::INTERNAL_SERVER_ERROR,
523 "InvalidToken",
524 "Token could not be verified",
525 );
526 }
527 }
528 } else {
529 // No verification code provided but captcha is required
530 log::warn!(
531 "No verification code provided for account creation: {}",
532 account_request.handle
533 );
534 return json_error_response(
535 StatusCode::BAD_REQUEST,
536 "InvalidRequest",
537 "Verification is now required on this server.",
538 );
539 }
540 }
541
542 // Rebuild the request with the same body and headers
543 proxy_post(&state, &headers, "/xrpc/com.atproto.server.createAccount", body_bytes.to_vec()).await
544}