Microservice to bring 2FA to self hosted PDSes
at feature/admin-rbac 254 lines 8.7 kB view raw
1use super::session; 2use crate::AppState; 3use crate::admin::store::SqlAuthStore; 4use axum::{ 5 extract::{Query, State}, 6 http::StatusCode, 7 response::{Html, IntoResponse, Redirect, Response}, 8}; 9use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; 10use jacquard_identity::JacquardResolver; 11use jacquard_oauth::session::ClientSessionData; 12use jacquard_oauth::{ 13 atproto::{AtprotoClientMetadata, GrantType}, 14 client::OAuthClient, 15 session::ClientData, 16 types::{AuthorizeOptions, CallbackParams}, 17}; 18use serde::Deserialize; 19use sqlx::SqlitePool; 20use tracing::log; 21 22/// Type alias for the concrete OAuthClient we use. 23pub type AdminOAuthClient = OAuthClient<JacquardResolver, SqlAuthStore>; 24 25/// Initialize the OAuth client for admin portal authentication. 26pub fn init_oauth_client( 27 pds_hostname: &str, 28 pool: SqlitePool, 29) -> Result<AdminOAuthClient, anyhow::Error> { 30 // Build client metadata 31 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 32 .parse() 33 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_id URL"))?; 34 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname) 35 .parse() 36 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid redirect_uri URL"))?; 37 let client_uri = format!("https://{}/admin/", pds_hostname) 38 .parse() 39 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_uri URL"))?; 40 41 let config = AtprotoClientMetadata::new( 42 client_id, 43 Some(client_uri), 44 vec![redirect_uri], 45 vec![GrantType::AuthorizationCode], 46 vec![jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope")], 47 None, 48 ); 49 50 let client_data = ClientData::new(None, config); 51 let store = SqlAuthStore::new(pool); 52 let client = OAuthClient::new(store, client_data); 53 54 Ok(client) 55} 56 57/// GET /admin/client-metadata.json — Serves the OAuth client metadata. 58pub async fn client_metadata_json(State(state): State<AppState>) -> Response { 59 let pds_hostname = &state.app_config.pds_hostname; 60 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname); 61 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); 62 let client_uri = format!("https://{}/admin/", pds_hostname); 63 64 let metadata = serde_json::json!({ 65 "client_id": client_id, 66 "client_uri": client_uri, 67 "redirect_uris": [redirect_uri], 68 "grant_types": ["authorization_code"], 69 "response_types": ["code"], 70 "scope": "atproto", 71 "token_endpoint_auth_method": "none", 72 "application_type": "web", 73 "dpop_bound_access_tokens": true, 74 75 }); 76 77 ( 78 StatusCode::OK, 79 [(axum::http::header::CONTENT_TYPE, "application/json")], 80 serde_json::to_string_pretty(&metadata).unwrap_or_default(), 81 ) 82 .into_response() 83} 84 85/// GET /admin/login — Renders the login page. 86pub async fn get_login( 87 State(state): State<AppState>, 88 Query(params): Query<LoginQueryParams>, 89) -> Response { 90 let mut data = serde_json::json!({ 91 "pds_hostname": state.app_config.pds_hostname, 92 }); 93 94 if let Some(error) = params.error { 95 data["error"] = serde_json::Value::String(error); 96 } 97 98 use axum_template::TemplateEngine; 99 match state.template_engine.render("admin/login.hbs", data) { 100 Ok(html) => Html(html).into_response(), 101 Err(e) => { 102 tracing::error!("Failed to render login template: {}", e); 103 StatusCode::INTERNAL_SERVER_ERROR.into_response() 104 } 105 } 106} 107 108#[derive(Debug, Deserialize)] 109pub struct LoginQueryParams { 110 pub error: Option<String>, 111} 112 113#[derive(Debug, Deserialize)] 114pub struct LoginForm { 115 pub handle: String, 116} 117 118/// POST /admin/login — Initiates the OAuth flow. 119pub async fn post_login( 120 State(state): State<AppState>, 121 axum::extract::Form(form): axum::extract::Form<LoginForm>, 122) -> Response { 123 let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 124 Some(client) => client, 125 None => return StatusCode::NOT_FOUND.into_response(), 126 }; 127 128 let pds_hostname = &state.app_config.pds_hostname; 129 let redirect_uri: url::Url = match format!("https://{}/admin/oauth/callback", pds_hostname) 130 .parse() 131 { 132 Ok(u) => u, 133 Err(_) => { 134 return Redirect::to("/admin/login?error=Invalid+server+configuration").into_response(); 135 } 136 }; 137 138 let options = AuthorizeOptions { 139 redirect_uri: Some(redirect_uri), 140 scopes: vec![ 141 jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"), 142 jacquard_oauth::scopes::Scope::parse("transition:generic").expect("valid scope"), 143 ], 144 prompt: None, 145 state: None, 146 }; 147 148 match oauth_client.start_auth(&form.handle, options).await { 149 Ok(auth_url) => Redirect::to(&auth_url).into_response(), 150 Err(e) => { 151 tracing::error!("OAuth start_auth failed: {}", e); 152 let msg = format!("Login failed: {}", e); 153 let error_msg = urlencoding::encode(&msg); 154 Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response() 155 } 156 } 157} 158 159#[derive(Debug, Deserialize)] 160pub struct OAuthCallbackParams { 161 pub code: String, 162 pub state: Option<String>, 163 pub iss: Option<String>, 164} 165 166/// GET /admin/oauth/callback — Handles the OAuth callback. 167pub async fn oauth_callback( 168 State(state): State<AppState>, 169 Query(params): Query<OAuthCallbackParams>, 170 jar: SignedCookieJar, 171) -> Response { 172 let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 173 Some(client) => client, 174 None => return StatusCode::NOT_FOUND.into_response(), 175 }; 176 177 let rbac = match &state.admin_rbac_config { 178 Some(rbac) => rbac, 179 None => return StatusCode::NOT_FOUND.into_response(), 180 }; 181 182 let callback_params = CallbackParams { 183 code: params.code.as_str().into(), 184 state: params.state.as_deref().map(Into::into), 185 iss: params.iss.as_deref().map(Into::into), 186 }; 187 188 // Exchange authorization code for session 189 let oauth_session = match oauth_client.callback(callback_params).await { 190 Ok(session) => session, 191 Err(e) => { 192 tracing::error!("OAuth callback failed: {}", e); 193 let msg = format!("Authentication failed: {}", e); 194 let error_msg = urlencoding::encode(&msg); 195 return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response(); 196 } 197 }; 198 199 // Extract DID and handle from the OAuth session 200 let (did, handle) = oauth_session.session_info().await; 201 let did_str = did.to_string(); 202 let handle_str = handle.to_string(); 203 log::info!("Authenticated as DID {} ({})", did_str, handle_str); 204 // Check if this DID is a member in the RBAC config 205 if !rbac.is_member(&did_str) { 206 tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); 207 return render_error( 208 &state, 209 "Access Denied", 210 &format!( 211 "Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.", 212 handle_str 213 ), 214 ); 215 } 216 217 // Create admin session 218 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 }; 229 230 // Set signed cookie 231 let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id); 232 cookie.set_http_only(true); 233 cookie.set_secure(true); 234 cookie.set_same_site(SameSite::Lax); 235 cookie.set_path("/admin/"); 236 237 let updated_jar = jar.add(cookie); 238 239 (updated_jar, Redirect::to("/admin/dashboard")).into_response() 240} 241 242fn render_error(state: &AppState, title: &str, message: &str) -> Response { 243 let data = serde_json::json!({ 244 "error_title": title, 245 "error_message": message, 246 "pds_hostname": state.app_config.pds_hostname, 247 }); 248 249 use axum_template::TemplateEngine; 250 match state.template_engine.render("admin/error.hbs", data) { 251 Ok(html) => Html(html).into_response(), 252 Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(), 253 } 254}