Microservice to bring 2FA to self hosted PDSes

down graded to a public client

+29 -71
+2 -1
.gitignore
··· 1 /target 2 .idea 3 - pds.env
··· 1 /target 2 .idea 3 + pds.env 4 + dev_admin_rbac.yaml
+27 -70
src/admin/oauth.rs
··· 1 use axum::{ 2 extract::{Query, State}, 3 http::StatusCode, ··· 16 }; 17 use jose_jwk::Jwk; 18 use serde::Deserialize; 19 - 20 - use crate::AppState; 21 22 use super::session; 23 ··· 26 27 /// Initialize the OAuth client for admin portal authentication. 28 pub fn init_oauth_client(pds_hostname: &str) -> Result<AdminOAuthClient, anyhow::Error> { 29 - // Generate ES256 keypair 30 - let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); 31 - let public_key = secret_key.public_key(); 32 - 33 - // Build JWK JSON manually with both public and private components 34 - let public_jwk_str = public_key.to_jwk_string(); 35 - let mut jwk: serde_json::Value = serde_json::from_str(&public_jwk_str)?; 36 - 37 - // Add the private key component 'd' 38 - let secret_scalar = secret_key.to_bytes(); 39 - let d_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret_scalar.as_slice()); 40 - let jwk_obj = jwk 41 - .as_object_mut() 42 - .ok_or_else(|| anyhow::anyhow!("JWK is not an object"))?; 43 - jwk_obj.insert("d".to_string(), serde_json::Value::String(d_b64)); 44 - 45 - // Add kid and alg 46 - let kid = uuid::Uuid::new_v4().to_string(); 47 - jwk_obj.insert("kid".to_string(), serde_json::Value::String(kid)); 48 - jwk_obj.insert( 49 - "alg".to_string(), 50 - serde_json::Value::String("ES256".to_string()), 51 - ); 52 - jwk_obj.insert( 53 - "use".to_string(), 54 - serde_json::Value::String("sig".to_string()), 55 - ); 56 - 57 - // Parse into jose-jwk type for Keyset 58 - let jose_jwk: Jwk = serde_json::from_value(jwk)?; 59 - let keyset = Keyset::try_from(vec![jose_jwk])?; 60 - 61 // Build client metadata 62 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 63 .parse() ··· 74 Some(client_uri), 75 vec![redirect_uri], 76 vec![GrantType::AuthorizationCode], 77 - vec![ 78 - jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"), 79 - ], 80 None, 81 ); 82 83 - let client_data = ClientData::new(Some(keyset), config); 84 let store = MemoryAuthStore::new(); 85 let client = OAuthClient::new(store, client_data); 86 ··· 99 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); 100 let client_uri = format!("https://{}/admin/", pds_hostname); 101 102 - let jwks = oauth_client.jwks(); 103 - 104 let metadata = serde_json::json!({ 105 "client_id": client_id, 106 "client_uri": client_uri, ··· 108 "grant_types": ["authorization_code"], 109 "response_types": ["code"], 110 "scope": "atproto", 111 - "token_endpoint_auth_method": "private_key_jwt", 112 - "token_endpoint_auth_signing_alg": "ES256", 113 "application_type": "web", 114 "dpop_bound_access_tokens": true, 115 - "jwks": jwks, 116 }); 117 118 ( ··· 137 } 138 139 use axum_template::TemplateEngine; 140 - match state.template_engine.render("admin/login.hbs", data) 141 - { 142 Ok(html) => Html(html).into_response(), 143 Err(e) => { 144 tracing::error!("Failed to render login template: {}", e); ··· 168 }; 169 170 let pds_hostname = &state.app_config.pds_hostname; 171 - let redirect_uri: url::Url = 172 - match format!("https://{}/admin/oauth/callback", pds_hostname).parse() { 173 - Ok(u) => u, 174 - Err(_) => { 175 - return Redirect::to("/admin/login?error=Invalid+server+configuration") 176 - .into_response() 177 - } 178 - }; 179 180 let options = AuthorizeOptions { 181 redirect_uri: Some(redirect_uri), ··· 242 let (did, handle) = oauth_session.session_info().await; 243 let did_str = did.to_string(); 244 let handle_str = handle.to_string(); 245 - 246 // Check if this DID is a member in the RBAC config 247 if !rbac.is_member(&did_str) { 248 tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); ··· 258 259 // Create admin session 260 let ttl_hours = state.app_config.admin_session_ttl_hours; 261 - let session_id = match session::create_session( 262 - &state.pds_gatekeeper_pool, 263 - &did_str, 264 - &handle_str, 265 - ttl_hours, 266 - ) 267 - .await 268 - { 269 - Ok(id) => id, 270 - Err(e) => { 271 - tracing::error!("Failed to create admin session: {}", e); 272 - return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 273 - } 274 - }; 275 276 // Set signed cookie 277 let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id); ··· 293 }); 294 295 use axum_template::TemplateEngine; 296 - match state.template_engine.render("admin/error.hbs", data) 297 - { 298 Ok(html) => Html(html).into_response(), 299 Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(), 300 }
··· 1 + use crate::AppState; 2 use axum::{ 3 extract::{Query, State}, 4 http::StatusCode, ··· 17 }; 18 use jose_jwk::Jwk; 19 use serde::Deserialize; 20 + use tracing::log; 21 22 use super::session; 23 ··· 26 27 /// Initialize the OAuth client for admin portal authentication. 28 pub fn init_oauth_client(pds_hostname: &str) -> Result<AdminOAuthClient, anyhow::Error> { 29 // Build client metadata 30 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 31 .parse() ··· 42 Some(client_uri), 43 vec![redirect_uri], 44 vec![GrantType::AuthorizationCode], 45 + vec![jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope")], 46 None, 47 ); 48 49 + let client_data = ClientData::new(None, config); 50 let store = MemoryAuthStore::new(); 51 let client = OAuthClient::new(store, client_data); 52 ··· 65 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); 66 let client_uri = format!("https://{}/admin/", pds_hostname); 67 68 let metadata = serde_json::json!({ 69 "client_id": client_id, 70 "client_uri": client_uri, ··· 72 "grant_types": ["authorization_code"], 73 "response_types": ["code"], 74 "scope": "atproto", 75 + "token_endpoint_auth_method": "none", 76 "application_type": "web", 77 "dpop_bound_access_tokens": true, 78 + 79 }); 80 81 ( ··· 100 } 101 102 use axum_template::TemplateEngine; 103 + match state.template_engine.render("admin/login.hbs", data) { 104 Ok(html) => Html(html).into_response(), 105 Err(e) => { 106 tracing::error!("Failed to render login template: {}", e); ··· 130 }; 131 132 let pds_hostname = &state.app_config.pds_hostname; 133 + let redirect_uri: url::Url = match format!("https://{}/admin/oauth/callback", pds_hostname) 134 + .parse() 135 + { 136 + Ok(u) => u, 137 + Err(_) => { 138 + return Redirect::to("/admin/login?error=Invalid+server+configuration").into_response(); 139 + } 140 + }; 141 142 let options = AuthorizeOptions { 143 redirect_uri: Some(redirect_uri), ··· 204 let (did, handle) = oauth_session.session_info().await; 205 let did_str = did.to_string(); 206 let handle_str = handle.to_string(); 207 + log::info!("Authenticated as DID {} ({})", did_str, handle_str); 208 // Check if this DID is a member in the RBAC config 209 if !rbac.is_member(&did_str) { 210 tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); ··· 220 221 // Create admin session 222 let ttl_hours = state.app_config.admin_session_ttl_hours; 223 + let session_id = 224 + match session::create_session(&state.pds_gatekeeper_pool, &did_str, &handle_str, ttl_hours) 225 + .await 226 + { 227 + Ok(id) => id, 228 + Err(e) => { 229 + tracing::error!("Failed to create admin session: {}", e); 230 + return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 231 + } 232 + }; 233 234 // Set signed cookie 235 let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id); ··· 251 }); 252 253 use axum_template::TemplateEngine; 254 + match state.template_engine.render("admin/error.hbs", data) { 255 Ok(html) => Html(html).into_response(), 256 Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(), 257 }