Microservice to bring 2FA to self hosted PDSes

Added oauth check

+89 -48
+9 -7
migrations/20260228000000_admin_sessions.sql
··· 1 - CREATE TABLE admin_sessions ( 2 - session_id VARCHAR(36) PRIMARY KEY, 3 - did VARCHAR NOT NULL, 4 - handle VARCHAR NOT NULL, 5 - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 - expires_at TIMESTAMP NOT NULL 1 + CREATE TABLE admin_sessions 2 + ( 3 + session_id VARCHAR(36) PRIMARY KEY, 4 + did VARCHAR NOT NULL, 5 + handle VARCHAR NOT NULL, 6 + oauth_session_id VARCHAR NOT NULL, 7 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 + expires_at TIMESTAMP NOT NULL 7 9 ); 8 - CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions(expires_at); 10 + CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions (expires_at);
+25 -14
src/admin/middleware.rs
··· 6 6 }; 7 7 use axum_extra::extract::cookie::SignedCookieJar; 8 8 9 - use crate::AppState; 10 - 11 9 use super::rbac::RbacConfig; 12 10 use super::session; 11 + use crate::AppState; 12 + use jacquard_common::types::did::Did; 13 13 14 14 /// Admin session data injected into request extensions. 15 15 #[derive(Debug, Clone)] ··· 36 36 impl AdminPermissions { 37 37 pub fn compute(rbac: &RbacConfig, did: &str) -> Self { 38 38 Self { 39 - can_view_accounts: rbac 40 - .can_access_endpoint(did, "com.atproto.admin.getAccountInfo") 39 + can_view_accounts: rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfo") 41 40 || rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfos"), 42 41 can_manage_takedowns: rbac 43 42 .can_access_endpoint(did, "com.atproto.admin.updateSubjectStatus"), 44 - can_delete_account: rbac 45 - .can_access_endpoint(did, "com.atproto.admin.deleteAccount"), 43 + can_delete_account: rbac.can_access_endpoint(did, "com.atproto.admin.deleteAccount"), 46 44 can_reset_password: rbac 47 45 .can_access_endpoint(did, "com.atproto.admin.updateAccountPassword"), 48 - can_create_account: rbac 49 - .can_access_endpoint(did, "com.atproto.server.createAccount"), 50 - can_manage_invites: rbac 51 - .can_access_endpoint(did, "com.atproto.admin.getInviteCodes"), 52 - can_create_invite: rbac 53 - .can_access_endpoint(did, "com.atproto.server.createInviteCode"), 46 + can_create_account: rbac.can_access_endpoint(did, "com.atproto.server.createAccount"), 47 + can_manage_invites: rbac.can_access_endpoint(did, "com.atproto.admin.getInviteCodes"), 48 + can_create_invite: rbac.can_access_endpoint(did, "com.atproto.server.createInviteCode"), 54 49 can_send_email: rbac.can_access_endpoint(did, "com.atproto.admin.sendEmail"), 55 - can_request_crawl: rbac 56 - .can_access_endpoint(did, "com.atproto.sync.requestCrawl"), 50 + can_request_crawl: rbac.can_access_endpoint(did, "com.atproto.sync.requestCrawl"), 57 51 } 58 52 } 59 53 } ··· 91 85 // Verify the DID is still a valid member 92 86 if !rbac.is_member(&session_row.did) { 93 87 return Redirect::to("/admin/login").into_response(); 88 + } 89 + 90 + let oauth_client = if let Some(client) = &state.admin_oauth_client { 91 + client 92 + } else { 93 + return Redirect::to("/admin/login").into_response(); 94 + }; 95 + 96 + let did: Did = session_row.did.clone().into(); 97 + let oauth_session_id = session_row.oauth_session_id.clone(); 98 + match oauth_client.restore(&did, oauth_session_id.as_str()).await { 99 + Ok(_) => {} 100 + Err(e) => { 101 + tracing::error!("Failed to restore admin session: {}", e); 102 + let error_msg = e.to_string(); 103 + return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response(); 104 + } 94 105 } 95 106 96 107 let roles = rbac.get_member_roles(&session_row.did);
+43 -14
src/admin/oauth.rs
··· 7 7 response::{Html, IntoResponse, Redirect, Response}, 8 8 }; 9 9 use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; 10 + use jacquard_common::CowStr; 10 11 use jacquard_identity::JacquardResolver; 12 + use jacquard_identity::resolver::IdentityResolver; 11 13 use jacquard_oauth::session::ClientSessionData; 12 14 use jacquard_oauth::{ 13 15 atproto::{AtprotoClientMetadata, GrantType}, ··· 197 199 }; 198 200 199 201 // Extract DID and handle from the OAuth session 200 - let (did, handle) = oauth_session.session_info().await; 202 + let (did, session_id) = oauth_session.session_info().await; 203 + let session_id_string = session_id.to_string(); 204 + log::info!("Authenticated as DID {}", did); 205 + let handle = match state.resolver.resolve_did_doc(&did).await { 206 + Ok(did_doc) => match did_doc.parse() { 207 + Ok(parsed_did_doc) => { 208 + let handles = parsed_did_doc.handles(); 209 + if handles.len() > 0 { 210 + handles[0].to_string() 211 + } else { 212 + "Not found".to_string() 213 + } 214 + } 215 + Err(err) => { 216 + tracing::error!("Failed to parse DID document for {}: {}", did, err); 217 + "Not found".to_string() 218 + } 219 + }, 220 + Err(err) => { 221 + tracing::error!("Failed to resolve DID document for {}: {}", did, err); 222 + //just default to the did 223 + "Not found".to_string() 224 + } 225 + }; 201 226 let did_str = did.to_string(); 202 - let handle_str = handle.to_string(); 203 - log::info!("Authenticated as DID {} ({})", did_str, handle_str); 227 + 204 228 // Check if this DID is a member in the RBAC config 205 229 if !rbac.is_member(&did_str) { 206 230 tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); ··· 209 233 "Access Denied", 210 234 &format!( 211 235 "Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.", 212 - handle_str 236 + did_str 213 237 ), 214 238 ); 215 239 } 216 240 217 241 // Create admin session 218 242 let ttl_hours = state.app_config.admin_session_ttl_hours; 219 - let session_id = 220 - match session::create_session(&state.pds_gatekeeper_pool, &did_str, &handle_str, ttl_hours) 221 - .await 222 - { 223 - Ok(id) => id, 224 - Err(e) => { 225 - tracing::error!("Failed to create admin session: {}", e); 226 - return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 227 - } 228 - }; 243 + let session_id = match session::create_session( 244 + &state.pds_gatekeeper_pool, 245 + &did_str, 246 + &handle, 247 + &session_id_string, 248 + ttl_hours, 249 + ) 250 + .await 251 + { 252 + Ok(id) => id, 253 + Err(e) => { 254 + tracing::error!("Failed to create admin session: {}", e); 255 + return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 256 + } 257 + }; 229 258 230 259 // Set signed cookie 231 260 let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id);
+2 -3
src/admin/routes.rs
··· 9 9 }; 10 10 use axum_extra::extract::cookie::{Cookie, SignedCookieJar}; 11 11 use jacquard::client::BasicClient; 12 + use jacquard_api::com_atproto::server::create_app_password::app_password_state::members::password; 12 13 use jacquard_common::types::handle::Handle; 13 14 use jacquard_identity::resolver::IdentityResolver; 14 15 use serde::Deserialize; 15 - use tracing::log; 16 16 17 17 // ─── Query parameter types ─────────────────────────────────────────────────── 18 18 ··· 461 461 ); 462 462 } 463 463 }; 464 - let client = BasicClient::unauthenticated(); 465 464 466 465 tracing::debug!("Resolving handle: {}", handle); 467 - look_up_did = match client.resolve_handle(&handle).await { 466 + look_up_did = match state.resolver.resolve_handle(&handle).await { 468 467 Ok(did) => did.to_string(), 469 468 Err(e) => { 470 469 tracing::warn!("Failed to resolve handle '{}': {}", handle, e);
+6 -6
src/admin/session.rs
··· 8 8 pub session_id: String, 9 9 pub did: String, 10 10 pub handle: String, 11 + pub oauth_session_id: String, 11 12 pub created_at: String, 12 13 pub expires_at: String, 13 14 } ··· 17 18 pool: &SqlitePool, 18 19 did: &str, 19 20 handle: &str, 21 + oauth_session_id: &str, 20 22 ttl_hours: u64, 21 23 ) -> Result<String> { 22 24 let session_id = Uuid::new_v4().to_string(); ··· 25 27 let expires_at = (now + chrono::Duration::hours(ttl_hours as i64)).to_rfc3339(); 26 28 27 29 sqlx::query( 28 - "INSERT INTO admin_sessions (session_id, did, handle, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", 30 + "INSERT INTO admin_sessions (session_id, did, handle, oauth_session_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", 29 31 ) 30 32 .bind(&session_id) 31 33 .bind(did) 32 34 .bind(handle) 35 + .bind(oauth_session_id) 33 36 .bind(&created_at) 34 37 .bind(&expires_at) 35 38 .execute(pool) ··· 39 42 } 40 43 41 44 /// Looks up a session by ID. Returns None if the session does not exist or has expired. 42 - pub async fn get_session( 43 - pool: &SqlitePool, 44 - session_id: &str, 45 - ) -> Result<Option<AdminSessionRow>> { 45 + pub async fn get_session(pool: &SqlitePool, session_id: &str) -> Result<Option<AdminSessionRow>> { 46 46 let now = Utc::now().to_rfc3339(); 47 47 48 48 let row = sqlx::query_as::<_, AdminSessionRow>( 49 - "SELECT session_id, did, handle, created_at, expires_at FROM admin_sessions WHERE session_id = ? AND expires_at > ?", 49 + "SELECT session_id, did, handle, oauth_session_id, created_at, expires_at FROM admin_sessions WHERE session_id = ? AND expires_at > ?", 50 50 ) 51 51 .bind(session_id) 52 52 .bind(&now)
+4 -4
src/main.rs
··· 19 19 use handlebars::Handlebars; 20 20 use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; 21 21 use jacquard_common::types::did::Did; 22 - use jacquard_identity::{PublicResolver, resolver::PlcSource}; 22 + use jacquard_identity::{JacquardResolver, PublicResolver, resolver::PlcSource}; 23 23 use rand::Rng; 24 24 use rust_embed::RustEmbed; 25 25 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode}; ··· 250 250 let account_db_url = format!("{pds_root}/account.sqlite"); 251 251 252 252 let account_options = SqliteConnectOptions::new() 253 - .journal_mode(SqliteJournalMode::Wal) 253 + // .journal_mode(SqliteJournalMode::Wal) 254 254 .filename(account_db_url) 255 255 .busy_timeout(Duration::from_secs(5)); 256 256 ··· 303 303 let plc_source_url = 304 304 env::var("PDS_DID_PLC_URL").unwrap_or_else(|_| "https://plc.directory".to_string()); 305 305 let plc_source = PlcSource::PlcDirectory { 306 - base: plc_source_url.parse().unwrap(), 306 + base: plc_source_url.parse()?, 307 307 }; 308 308 let mut resolver = PublicResolver::default(); 309 - resolver = resolver.with_plc_source(plc_source.clone()); 309 + resolver = resolver.with_plc_source(plc_source.clone()).with_cache(); 310 310 311 311 let app_config = AppConfig::new(); 312 312