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