Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(api): extract common helpers module, extend API error types with auth methods #78

merged opened by oyster.cafe targeting main from refactor/api
Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mhi3qdcvjs22
+491 -68
Diff #0
+20 -20
Cargo.lock
··· 6094 6094 6095 6095 [[package]] 6096 6096 name = "tranquil-api" 6097 - version = "0.4.5" 6097 + version = "0.4.6" 6098 6098 dependencies = [ 6099 6099 "anyhow", 6100 6100 "axum", ··· 6142 6142 6143 6143 [[package]] 6144 6144 name = "tranquil-auth" 6145 - version = "0.4.5" 6145 + version = "0.4.6" 6146 6146 dependencies = [ 6147 6147 "anyhow", 6148 6148 "base32", ··· 6165 6165 6166 6166 [[package]] 6167 6167 name = "tranquil-cache" 6168 - version = "0.4.5" 6168 + version = "0.4.6" 6169 6169 dependencies = [ 6170 6170 "async-trait", 6171 6171 "base64 0.22.1", ··· 6179 6179 6180 6180 [[package]] 6181 6181 name = "tranquil-comms" 6182 - version = "0.4.5" 6182 + version = "0.4.6" 6183 6183 dependencies = [ 6184 6184 "async-trait", 6185 6185 "base64 0.22.1", ··· 6194 6194 6195 6195 [[package]] 6196 6196 name = "tranquil-config" 6197 - version = "0.4.5" 6197 + version = "0.4.6" 6198 6198 dependencies = [ 6199 6199 "confique", 6200 6200 "serde", ··· 6202 6202 6203 6203 [[package]] 6204 6204 name = "tranquil-crypto" 6205 - version = "0.4.5" 6205 + version = "0.4.6" 6206 6206 dependencies = [ 6207 6207 "aes-gcm", 6208 6208 "base64 0.22.1", ··· 6218 6218 6219 6219 [[package]] 6220 6220 name = "tranquil-db" 6221 - version = "0.4.5" 6221 + version = "0.4.6" 6222 6222 dependencies = [ 6223 6223 "async-trait", 6224 6224 "chrono", ··· 6235 6235 6236 6236 [[package]] 6237 6237 name = "tranquil-db-traits" 6238 - version = "0.4.5" 6238 + version = "0.4.6" 6239 6239 dependencies = [ 6240 6240 "async-trait", 6241 6241 "base64 0.22.1", ··· 6251 6251 6252 6252 [[package]] 6253 6253 name = "tranquil-infra" 6254 - version = "0.4.5" 6254 + version = "0.4.6" 6255 6255 dependencies = [ 6256 6256 "async-trait", 6257 6257 "bytes", ··· 6262 6262 6263 6263 [[package]] 6264 6264 name = "tranquil-lexicon" 6265 - version = "0.4.5" 6265 + version = "0.4.6" 6266 6266 dependencies = [ 6267 6267 "chrono", 6268 6268 "hickory-resolver", ··· 6280 6280 6281 6281 [[package]] 6282 6282 name = "tranquil-oauth" 6283 - version = "0.4.5" 6283 + version = "0.4.6" 6284 6284 dependencies = [ 6285 6285 "anyhow", 6286 6286 "axum", ··· 6303 6303 6304 6304 [[package]] 6305 6305 name = "tranquil-oauth-server" 6306 - version = "0.4.5" 6306 + version = "0.4.6" 6307 6307 dependencies = [ 6308 6308 "axum", 6309 6309 "base64 0.22.1", ··· 6336 6336 6337 6337 [[package]] 6338 6338 name = "tranquil-pds" 6339 - version = "0.4.5" 6339 + version = "0.4.6" 6340 6340 dependencies = [ 6341 6341 "aes-gcm", 6342 6342 "anyhow", ··· 6424 6424 6425 6425 [[package]] 6426 6426 name = "tranquil-repo" 6427 - version = "0.4.5" 6427 + version = "0.4.6" 6428 6428 dependencies = [ 6429 6429 "bytes", 6430 6430 "cid", ··· 6436 6436 6437 6437 [[package]] 6438 6438 name = "tranquil-ripple" 6439 - version = "0.4.5" 6439 + version = "0.4.6" 6440 6440 dependencies = [ 6441 6441 "async-trait", 6442 6442 "backon", ··· 6461 6461 6462 6462 [[package]] 6463 6463 name = "tranquil-scopes" 6464 - version = "0.4.5" 6464 + version = "0.4.6" 6465 6465 dependencies = [ 6466 6466 "axum", 6467 6467 "futures", ··· 6477 6477 6478 6478 [[package]] 6479 6479 name = "tranquil-server" 6480 - version = "0.4.5" 6480 + version = "0.4.6" 6481 6481 dependencies = [ 6482 6482 "axum", 6483 6483 "clap", ··· 6497 6497 6498 6498 [[package]] 6499 6499 name = "tranquil-storage" 6500 - version = "0.4.5" 6500 + version = "0.4.6" 6501 6501 dependencies = [ 6502 6502 "async-trait", 6503 6503 "aws-config", ··· 6514 6514 6515 6515 [[package]] 6516 6516 name = "tranquil-sync" 6517 - version = "0.4.5" 6517 + version = "0.4.6" 6518 6518 dependencies = [ 6519 6519 "anyhow", 6520 6520 "axum", ··· 6536 6536 6537 6537 [[package]] 6538 6538 name = "tranquil-types" 6539 - version = "0.4.5" 6539 + version = "0.4.6" 6540 6540 dependencies = [ 6541 6541 "chrono", 6542 6542 "cid",
+1 -1
Cargo.toml
··· 24 24 ] 25 25 26 26 [workspace.package] 27 - version = "0.4.5" 27 + version = "0.4.6" 28 28 edition = "2024" 29 29 license = "AGPL-3.0-or-later" 30 30
+262
crates/tranquil-api/src/common.rs
··· 1 + use bcrypt::DEFAULT_COST; 2 + use chrono::{DateTime, Utc}; 3 + use std::collections::HashMap; 4 + use tracing::error; 5 + use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository}; 6 + use tranquil_pds::api::error::ApiError; 7 + use tranquil_pds::api::error::DbResultExt; 8 + use tranquil_pds::types::{AtIdentifier, Did, Handle}; 9 + 10 + pub struct ResolvedRepo { 11 + pub user_id: uuid::Uuid, 12 + pub did: Did, 13 + pub handle: Handle, 14 + } 15 + 16 + fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> { 17 + let raw = handle.as_str(); 18 + let qualified = match raw.contains('.') { 19 + true => return Ok(handle.clone()), 20 + false => format!( 21 + "{}.{}", 22 + raw, 23 + tranquil_config::get().server.hostname_without_port() 24 + ), 25 + }; 26 + qualified 27 + .parse() 28 + .map_err(|_| ApiError::InvalidRequest("Invalid handle format".into())) 29 + } 30 + 31 + pub async fn resolve_repo( 32 + user_repo: &dyn UserRepository, 33 + repo: &AtIdentifier, 34 + ) -> Result<ResolvedRepo, ApiError> { 35 + let row = match repo { 36 + AtIdentifier::Did(did) => user_repo 37 + .get_by_did(did) 38 + .await 39 + .log_db_err("resolving repo by DID")?, 40 + AtIdentifier::Handle(handle) => { 41 + let qualified = qualify_handle(handle)?; 42 + user_repo 43 + .get_by_handle(&qualified) 44 + .await 45 + .log_db_err("resolving repo by handle")? 46 + } 47 + }; 48 + row.map(|r| ResolvedRepo { 49 + user_id: r.id, 50 + did: r.did, 51 + handle: r.handle, 52 + }) 53 + .ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) 54 + } 55 + 56 + pub async fn resolve_repo_user_id( 57 + user_repo: &dyn UserRepository, 58 + repo: &AtIdentifier, 59 + ) -> Result<uuid::Uuid, ApiError> { 60 + let id = match repo { 61 + AtIdentifier::Did(did) => user_repo 62 + .get_id_by_did(did) 63 + .await 64 + .log_db_err("resolving repo user ID by DID")?, 65 + AtIdentifier::Handle(handle) => { 66 + let qualified = qualify_handle(handle)?; 67 + user_repo 68 + .get_id_by_handle(&qualified) 69 + .await 70 + .log_db_err("resolving repo user ID by handle")? 71 + } 72 + }; 73 + id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) 74 + } 75 + 76 + pub fn group_invite_uses_by_code<U, F>( 77 + uses: Vec<tranquil_db_traits::InviteCodeUse>, 78 + map_use: F, 79 + ) -> HashMap<String, Vec<U>> 80 + where 81 + F: Fn(tranquil_db_traits::InviteCodeUse) -> U, 82 + { 83 + uses.into_iter().fold(HashMap::new(), |mut acc, u| { 84 + let code = u.code.clone(); 85 + acc.entry(code).or_default().push(map_use(u)); 86 + acc 87 + }) 88 + } 89 + 90 + pub fn resolve_also_known_as( 91 + overrides: Option<&DidWebOverrides>, 92 + current_handle: &str, 93 + ) -> Vec<String> { 94 + overrides 95 + .filter(|ovr| !ovr.also_known_as.is_empty()) 96 + .map(|ovr| ovr.also_known_as.clone()) 97 + .unwrap_or_else(|| vec![format!("at://{}", current_handle)]) 98 + } 99 + 100 + pub fn build_did_document( 101 + did: &str, 102 + also_known_as: Vec<String>, 103 + verification_methods: Vec<serde_json::Value>, 104 + service_endpoint: &str, 105 + ) -> serde_json::Value { 106 + serde_json::json!({ 107 + "@context": [ 108 + "https://www.w3.org/ns/did/v1", 109 + "https://w3id.org/security/multikey/v1", 110 + "https://w3id.org/security/suites/secp256k1-2019/v1" 111 + ], 112 + "id": did, 113 + "alsoKnownAs": also_known_as, 114 + "verificationMethod": verification_methods, 115 + "service": [{ 116 + "id": "#atproto_pds", 117 + "type": tranquil_pds::plc::ServiceType::Pds.as_str(), 118 + "serviceEndpoint": service_endpoint 119 + }] 120 + }) 121 + } 122 + 123 + pub async fn set_channel_verified_flag( 124 + user_repo: &dyn UserRepository, 125 + user_id: uuid::Uuid, 126 + channel: CommsChannel, 127 + ) -> Result<(), ApiError> { 128 + match channel { 129 + CommsChannel::Email => user_repo 130 + .set_email_verified_flag(user_id) 131 + .await 132 + .log_db_err("updating email verified status")?, 133 + CommsChannel::Discord => user_repo 134 + .set_discord_verified_flag(user_id) 135 + .await 136 + .log_db_err("updating discord verified status")?, 137 + CommsChannel::Telegram => user_repo 138 + .set_telegram_verified_flag(user_id) 139 + .await 140 + .log_db_err("updating telegram verified status")?, 141 + CommsChannel::Signal => user_repo 142 + .set_signal_verified_flag(user_id) 143 + .await 144 + .log_db_err("updating signal verified status")?, 145 + }; 146 + Ok(()) 147 + } 148 + 149 + pub struct ChannelInput<'a> { 150 + pub email: Option<&'a str>, 151 + pub discord_username: Option<&'a str>, 152 + pub telegram_username: Option<&'a str>, 153 + pub signal_username: Option<&'a str>, 154 + } 155 + 156 + pub fn extract_verification_recipient( 157 + channel: CommsChannel, 158 + input: &ChannelInput<'_>, 159 + ) -> Result<String, ApiError> { 160 + match channel { 161 + CommsChannel::Email => match input.email { 162 + Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()), 163 + _ => Err(ApiError::MissingEmail), 164 + }, 165 + CommsChannel::Discord => match input.discord_username { 166 + Some(username) if !username.trim().is_empty() => { 167 + let clean = username.trim().to_lowercase(); 168 + if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { 169 + return Err(ApiError::InvalidRequest( 170 + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), 171 + )); 172 + } 173 + Ok(clean) 174 + } 175 + _ => Err(ApiError::MissingDiscordId), 176 + }, 177 + CommsChannel::Telegram => match input.telegram_username { 178 + Some(username) if !username.trim().is_empty() => { 179 + let clean = username.trim().trim_start_matches('@'); 180 + if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { 181 + return Err(ApiError::InvalidRequest( 182 + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), 183 + )); 184 + } 185 + Ok(clean.to_string()) 186 + } 187 + _ => Err(ApiError::MissingTelegramUsername), 188 + }, 189 + CommsChannel::Signal => match input.signal_username { 190 + Some(username) if !username.trim().is_empty() => { 191 + Ok(username.trim().trim_start_matches('@').to_lowercase()) 192 + } 193 + _ => Err(ApiError::MissingSignalNumber), 194 + }, 195 + } 196 + } 197 + 198 + pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> { 199 + if !tranquil_pds::util::is_self_hosted_did_web_enabled() { 200 + return Err(ApiError::SelfHostedDidWebDisabled); 201 + } 202 + let encoded_handle = handle.replace(':', "%3A"); 203 + Ok(format!("did:web:{}", encoded_handle)) 204 + } 205 + 206 + pub enum CredentialMatch { 207 + MainPassword, 208 + AppPassword { 209 + name: String, 210 + scopes: Option<String>, 211 + controller_did: Option<Did>, 212 + }, 213 + } 214 + 215 + pub async fn verify_credential( 216 + session_repo: &dyn SessionRepository, 217 + user_id: uuid::Uuid, 218 + password: &str, 219 + password_hash: Option<&str>, 220 + ) -> Option<CredentialMatch> { 221 + let main_valid = password_hash 222 + .map(|h| bcrypt::verify(password, h).unwrap_or(false)) 223 + .unwrap_or(false); 224 + if main_valid { 225 + return Some(CredentialMatch::MainPassword); 226 + } 227 + let app_passwords = session_repo 228 + .get_app_passwords_for_login(user_id) 229 + .await 230 + .unwrap_or_default(); 231 + app_passwords 232 + .into_iter() 233 + .find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false)) 234 + .map(|app| CredentialMatch::AppPassword { 235 + name: app.name, 236 + scopes: app.scopes, 237 + controller_did: app.created_by_controller_did, 238 + }) 239 + } 240 + 241 + pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> { 242 + bcrypt::hash(value, DEFAULT_COST).map_err(|e| { 243 + error!("Bcrypt hash error: {:?}", e); 244 + ApiError::InternalError(None) 245 + }) 246 + } 247 + 248 + pub fn validate_token_hash( 249 + expires_at: Option<DateTime<Utc>>, 250 + stored_hash: &str, 251 + input_token: &str, 252 + expired_err: ApiError, 253 + invalid_err: ApiError, 254 + ) -> Result<(), ApiError> { 255 + match expires_at { 256 + Some(exp) if exp < Utc::now() => Err(expired_err), 257 + _ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) { 258 + true => Ok(()), 259 + false => Err(invalid_err), 260 + }, 261 + } 262 + }
+2 -2
crates/tranquil-api/src/lib.rs
··· 1 1 pub mod actor; 2 2 pub mod admin; 3 3 pub mod age_assurance; 4 + pub mod common; 4 5 pub mod delegation; 5 6 pub mod discord_webhook; 6 7 pub mod identity; ··· 10 11 pub mod server; 11 12 pub mod telegram_webhook; 12 13 pub mod temp; 13 - pub mod verification; 14 14 15 15 use tranquil_pds::state::AppState; 16 16 ··· 386 386 ) 387 387 .route( 388 388 "/_account.confirmChannelVerification", 389 - post(verification::confirm_channel_verification), 389 + post(server::confirm_channel_verification), 390 390 ) 391 391 .route("/_account.verifyToken", post(server::verify_token)) 392 392 .route(
+50 -1
crates/tranquil-pds/src/api/error.rs
··· 112 112 SsoLinkNotFound, 113 113 AuthFactorTokenRequired, 114 114 LegacyLoginBlocked, 115 + ReauthRequired { 116 + methods: Vec<String>, 117 + }, 118 + MfaVerificationRequiredWithMethods { 119 + methods: Vec<String>, 120 + }, 115 121 } 116 122 117 123 impl ApiError { ··· 131 137 | Self::InvalidPassword(_) 132 138 | Self::InvalidToken(_) 133 139 | Self::PasskeyCounterAnomaly 134 - | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, 140 + | Self::OAuthExpiredToken(_) 141 + | Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED, 135 142 Self::InvalidCode(_) => StatusCode::BAD_REQUEST, 136 143 Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, 137 144 Self::Forbidden ··· 142 149 | Self::AccountMigrated 143 150 | Self::AccountNotVerified 144 151 | Self::MfaVerificationRequired 152 + | Self::MfaVerificationRequiredWithMethods { .. } 145 153 | Self::AuthorizationError(_) => StatusCode::FORBIDDEN, 146 154 Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS, 147 155 Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, ··· 310 318 Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"), 311 319 Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"), 312 320 Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"), 321 + Self::ReauthRequired { .. } => Cow::Borrowed("ReauthRequired"), 322 + Self::MfaVerificationRequiredWithMethods { .. } => { 323 + Cow::Borrowed("MfaVerificationRequired") 324 + } 313 325 } 314 326 } 315 327 fn message(&self) -> String { ··· 471 483 Self::AuthFactorTokenRequired => { 472 484 "A sign-in code has been sent to your email address".into() 473 485 } 486 + Self::ReauthRequired { .. } => { 487 + "Re-authentication required for this action".into() 488 + } 489 + Self::MfaVerificationRequiredWithMethods { .. } => { 490 + "This sensitive operation requires MFA verification".into() 491 + } 474 492 } 475 493 } 476 494 pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self { ··· 499 517 500 518 impl IntoResponse for ApiError { 501 519 fn into_response(self) -> Response { 520 + match self { 521 + Self::ReauthRequired { ref methods } => { 522 + return ( 523 + self.status_code(), 524 + Json(serde_json::json!({ 525 + "error": "ReauthRequired", 526 + "message": "Re-authentication required for this action", 527 + "reauthMethods": methods, 528 + })), 529 + ) 530 + .into_response(); 531 + } 532 + Self::MfaVerificationRequiredWithMethods { ref methods } => { 533 + return ( 534 + self.status_code(), 535 + Json(serde_json::json!({ 536 + "error": "MfaVerificationRequired", 537 + "message": "This sensitive operation requires MFA verification", 538 + "reauthMethods": methods, 539 + })), 540 + ) 541 + .into_response(); 542 + } 543 + _ => {} 544 + } 502 545 let body = ErrorBody { 503 546 error: self.error_name(), 504 547 message: self.message(), ··· 594 637 } 595 638 } 596 639 640 + impl From<crate::auth::scope_verified::ScopeVerificationError> for ApiError { 641 + fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self { 642 + Self::InsufficientScope(Some(e.to_string())) 643 + } 644 + } 645 + 597 646 impl From<crate::handle::HandleResolutionError> for ApiError { 598 647 fn from(e: crate::handle::HandleResolutionError) -> Self { 599 648 match e {
+4 -2
crates/tranquil-pds/src/api/mod.rs
··· 7 7 pub use error::ApiError; 8 8 pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit}; 9 9 pub use responses::{ 10 - DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, 11 - StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, 10 + AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput, 11 + EmptyResponse, EnabledResponse, HasPasswordResponse, InUseOutput, OptionsResponse, 12 + PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse, 13 + TokenRequiredResponse, VerifiedResponse, 12 14 };
+1 -1
crates/tranquil-pds/src/api/proxy.rs
··· 247 247 &resolved.did, 248 248 method, 249 249 ) { 250 - return e; 250 + return e.into_response(); 251 251 } 252 252 253 253 let key_bytes = match auth_user.key_bytes {
+57
crates/tranquil-pds/src/api/responses.rs
··· 116 116 Json(Self { options }) 117 117 } 118 118 } 119 + 120 + #[derive(Debug, Serialize)] 121 + #[serde(rename_all = "camelCase")] 122 + pub struct AccountsOutput<T: Serialize> { 123 + pub accounts: T, 124 + } 125 + 126 + #[derive(Debug, Serialize)] 127 + #[serde(rename_all = "camelCase")] 128 + pub struct AuditLogOutput<T: Serialize> { 129 + pub entries: T, 130 + pub total: i64, 131 + } 132 + 133 + #[derive(Debug, Serialize)] 134 + #[serde(rename_all = "camelCase")] 135 + pub struct ControllersOutput<T: Serialize> { 136 + pub controllers: T, 137 + } 138 + 139 + #[derive(Debug, Serialize)] 140 + #[serde(rename_all = "camelCase")] 141 + pub struct PresetsOutput<T: Serialize> { 142 + pub presets: T, 143 + } 144 + 145 + #[derive(Debug, Serialize)] 146 + #[serde(rename_all = "camelCase")] 147 + pub struct EmailUpdateStatusOutput { 148 + pub pending: bool, 149 + pub authorized: bool, 150 + pub new_email: Option<String>, 151 + } 152 + 153 + #[derive(Debug, Serialize)] 154 + #[serde(rename_all = "camelCase")] 155 + pub struct InUseOutput { 156 + pub in_use: bool, 157 + } 158 + 159 + #[derive(Debug, Serialize)] 160 + #[serde(rename_all = "camelCase")] 161 + pub struct PasswordResetOutput { 162 + pub success: bool, 163 + #[serde(skip_serializing_if = "Option::is_none")] 164 + pub multiple_accounts: Option<bool>, 165 + #[serde(skip_serializing_if = "Option::is_none")] 166 + pub account_count: Option<i64>, 167 + #[serde(skip_serializing_if = "Option::is_none")] 168 + pub message: Option<String>, 169 + } 170 + 171 + #[derive(Debug, Serialize)] 172 + #[serde(rename_all = "camelCase")] 173 + pub struct PreferredLocaleOutput { 174 + pub preferred_locale: Option<String>, 175 + }
+7 -10
crates/tranquil-pds/src/auth/account_verified.rs
··· 1 - use axum::response::{IntoResponse, Response}; 2 - 3 1 use super::AuthenticatedUser; 4 2 use crate::api::error::ApiError; 5 3 use crate::state::AppState; ··· 22 20 pub async fn require_verified_or_delegated<'a>( 23 21 state: &AppState, 24 22 user: &'a AuthenticatedUser, 25 - ) -> Result<AccountVerified<'a>, Response> { 23 + ) -> Result<AccountVerified<'a>, ApiError> { 26 24 let is_verified = state 27 25 .user_repo 28 26 .has_verified_comms_channel(&user.did) ··· 43 41 return Ok(AccountVerified { user }); 44 42 } 45 43 46 - Err(ApiError::AccountNotVerified.into_response()) 44 + Err(ApiError::AccountNotVerified) 47 45 } 48 46 49 - pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> { 47 + pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), ApiError> { 50 48 match state.user_repo.is_account_migrated(did).await { 51 - Ok(true) => Err(ApiError::AccountMigrated.into_response()), 49 + Ok(true) => Err(ApiError::AccountMigrated), 52 50 Ok(false) => Ok(()), 53 51 Err(e) => { 54 52 tracing::error!("Failed to check migration status: {:?}", e); 55 - Err( 56 - ApiError::InternalError(Some("Failed to verify migration status".into())) 57 - .into_response(), 58 - ) 53 + Err(ApiError::InternalError(Some( 54 + "Failed to verify migration status".into(), 55 + ))) 59 56 } 60 57 } 61 58 }
+22 -4
crates/tranquil-pds/src/auth/extractor.rs
··· 12 12 is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth, 13 13 }; 14 14 use crate::api::error::ApiError; 15 - use crate::oauth::scopes::{RepoAction, ScopePermissions}; 15 + use crate::oauth::scopes::{AccountAction, AccountAttr, RepoAction, ScopePermissions}; 16 16 use crate::state::AppState; 17 17 use crate::types::Did; 18 18 use crate::util::build_full_url; ··· 130 130 None 131 131 } 132 132 133 + pub fn extract_jti_from_headers(headers: &axum::http::HeaderMap) -> Option<String> { 134 + let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?; 135 + let token = extract_bearer_token_from_header(Some(auth_header))?; 136 + tranquil_auth::get_jti_from_token(&token).ok() 137 + } 138 + 133 139 pub trait AuthPolicy: Send + Sync + 'static { 134 140 fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>; 135 141 } ··· 356 362 self.0.permissions() 357 363 } 358 364 359 - #[allow(clippy::result_large_err)] 360 - pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> { 365 + pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), ApiError> { 361 366 if !self.needs_scope_check() { 362 367 return Ok(()); 363 368 } 364 369 self.permissions() 365 370 .assert_repo(action, collection) 366 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 371 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 372 + } 373 + 374 + pub fn check_account_scope( 375 + &self, 376 + attr: AccountAttr, 377 + action: AccountAction, 378 + ) -> Result<(), ApiError> { 379 + if !self.needs_scope_check() { 380 + return Ok(()); 381 + } 382 + self.permissions() 383 + .assert_account(attr, action) 384 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 367 385 } 368 386 } 369 387
+43 -11
crates/tranquil-pds/src/auth/mfa_verified.rs
··· 1 - use axum::response::Response; 1 + use crate::api::error::ApiError; 2 2 3 3 use super::AuthenticatedUser; 4 4 use crate::state::AppState; ··· 73 73 pub async fn require_legacy_session_mfa<'a>( 74 74 state: &AppState, 75 75 user: &'a AuthenticatedUser, 76 - ) -> Result<MfaVerified<'a>, Response> { 77 - use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response}; 76 + ) -> Result<MfaVerified<'a>, ApiError> { 77 + use crate::auth::reauth::check_legacy_session_mfa; 78 78 79 79 if check_legacy_session_mfa(&*state.session_repo, &user.did).await { 80 80 Ok(MfaVerified::from_session_reauth(user)) 81 81 } else { 82 - Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) 82 + let methods = crate::auth::reauth::get_available_reauth_methods( 83 + &*state.user_repo, 84 + &*state.session_repo, 85 + &user.did, 86 + ) 87 + .await; 88 + Err(ApiError::MfaVerificationRequiredWithMethods { 89 + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), 90 + }) 83 91 } 84 92 } 85 93 86 94 pub async fn require_reauth_window<'a>( 87 95 state: &AppState, 88 96 user: &'a AuthenticatedUser, 89 - ) -> Result<MfaVerified<'a>, Response> { 90 - use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; 97 + ) -> Result<MfaVerified<'a>, ApiError> { 98 + use crate::auth::reauth::REAUTH_WINDOW_SECONDS; 91 99 use chrono::Utc; 92 100 93 101 let status = state ··· 105 113 return Ok(MfaVerified::from_session_reauth(user)); 106 114 } 107 115 } 108 - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) 116 + let methods = crate::auth::reauth::get_available_reauth_methods( 117 + &*state.user_repo, 118 + &*state.session_repo, 119 + &user.did, 120 + ) 121 + .await; 122 + Err(ApiError::ReauthRequired { 123 + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), 124 + }) 109 125 } 110 126 None => { 111 - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) 127 + let methods = crate::auth::reauth::get_available_reauth_methods( 128 + &*state.user_repo, 129 + &*state.session_repo, 130 + &user.did, 131 + ) 132 + .await; 133 + Err(ApiError::ReauthRequired { 134 + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), 135 + }) 112 136 } 113 137 } 114 138 } ··· 116 140 pub async fn require_reauth_window_if_available<'a>( 117 141 state: &AppState, 118 142 user: &'a AuthenticatedUser, 119 - ) -> Result<Option<MfaVerified<'a>>, Response> { 120 - use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response}; 143 + ) -> Result<Option<MfaVerified<'a>>, ApiError> { 144 + use crate::auth::reauth::check_reauth_required_cached; 121 145 122 146 let has_password = state 123 147 .user_repo ··· 144 168 } 145 169 146 170 if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await { 147 - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) 171 + let methods = crate::auth::reauth::get_available_reauth_methods( 172 + &*state.user_repo, 173 + &*state.session_repo, 174 + &user.did, 175 + ) 176 + .await; 177 + Err(ApiError::ReauthRequired { 178 + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), 179 + }) 148 180 } else { 149 181 Ok(Some(MfaVerified::from_session_reauth(user))) 150 182 }
+1 -1
crates/tranquil-pds/src/auth/mod.rs
··· 29 29 pub use extractor::{ 30 30 Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken, 31 31 NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header, 32 - extract_bearer_token_from_header, 32 + extract_bearer_token_from_header, extract_jti_from_headers, 33 33 }; 34 34 pub use mfa_verified::{ 35 35 MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window,
+11 -1
crates/tranquil-pds/src/auth/reauth.rs
··· 17 17 Passkey, 18 18 } 19 19 20 + impl ReauthMethod { 21 + pub fn as_str(&self) -> &'static str { 22 + match self { 23 + Self::Password => "password", 24 + Self::Totp => "totp", 25 + Self::Passkey => "passkey", 26 + } 27 + } 28 + } 29 + 20 30 fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> bool { 21 31 match last_reauth_at { 22 32 None => true, ··· 27 37 } 28 38 } 29 39 30 - async fn get_available_reauth_methods( 40 + pub async fn get_available_reauth_methods( 31 41 user_repo: &dyn UserRepository, 32 42 _session_repo: &dyn SessionRepository, 33 43 did: &crate::types::Did,
+10 -14
crates/tranquil-pds/src/auth/scope_check.rs
··· 1 - #![allow(clippy::result_large_err)] 2 - 3 - use axum::response::{IntoResponse, Response}; 4 - 5 1 use crate::api::error::ApiError; 6 2 use crate::oauth::scopes::{ 7 3 AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, ··· 24 20 scope: Option<&str>, 25 21 action: RepoAction, 26 22 collection: &str, 27 - ) -> Result<(), Response> { 23 + ) -> Result<(), ApiError> { 28 24 if !requires_scope_check(auth_source, scope) { 29 25 return Ok(()); 30 26 } ··· 32 28 let permissions = ScopePermissions::from_scope_string(scope); 33 29 permissions 34 30 .assert_repo(action, collection) 35 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 31 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 36 32 } 37 33 38 34 pub fn check_blob_scope( 39 35 auth_source: &AuthSource, 40 36 scope: Option<&str>, 41 37 mime: &str, 42 - ) -> Result<(), Response> { 38 + ) -> Result<(), ApiError> { 43 39 if !requires_scope_check(auth_source, scope) { 44 40 return Ok(()); 45 41 } ··· 47 43 let permissions = ScopePermissions::from_scope_string(scope); 48 44 permissions 49 45 .assert_blob(mime) 50 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 46 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 51 47 } 52 48 53 49 pub fn check_rpc_scope( ··· 55 51 scope: Option<&str>, 56 52 aud: &str, 57 53 lxm: &str, 58 - ) -> Result<(), Response> { 54 + ) -> Result<(), ApiError> { 59 55 if !requires_scope_check(auth_source, scope) { 60 56 return Ok(()); 61 57 } ··· 63 59 let permissions = ScopePermissions::from_scope_string(scope); 64 60 permissions 65 61 .assert_rpc(aud, lxm) 66 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 62 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 67 63 } 68 64 69 65 pub fn check_account_scope( ··· 71 67 scope: Option<&str>, 72 68 attr: AccountAttr, 73 69 action: AccountAction, 74 - ) -> Result<(), Response> { 70 + ) -> Result<(), ApiError> { 75 71 if !requires_scope_check(auth_source, scope) { 76 72 return Ok(()); 77 73 } ··· 79 75 let permissions = ScopePermissions::from_scope_string(scope); 80 76 permissions 81 77 .assert_account(attr, action) 82 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 78 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 83 79 } 84 80 85 81 pub fn check_identity_scope( 86 82 auth_source: &AuthSource, 87 83 scope: Option<&str>, 88 84 attr: IdentityAttr, 89 - ) -> Result<(), Response> { 85 + ) -> Result<(), ApiError> { 90 86 if !requires_scope_check(auth_source, scope) { 91 87 return Ok(()); 92 88 } ··· 94 90 let permissions = ScopePermissions::from_scope_string(scope); 95 91 permissions 96 92 .assert_identity(attr) 97 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 93 + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) 98 94 }

History

1 round 0 comments
sign up or login to add to the discussion
oyster.cafe submitted #0
1 commit
expand
refactor(api): extract common helpers module, extend API error types with auth methods
expand 0 comments
pull request successfully merged