diff --git a/Cargo.lock b/Cargo.lock index 5a17f81..508344e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6094,7 +6094,7 @@ dependencies = [ [[package]] name = "tranquil-api" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "axum", @@ -6142,7 +6142,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "base32", @@ -6165,7 +6165,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -6194,7 +6194,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.4.5" +version = "0.4.6" dependencies = [ "confique", "serde", @@ -6202,7 +6202,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.4.5" +version = "0.4.6" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "chrono", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "base64 0.22.1", @@ -6251,7 +6251,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "bytes", @@ -6262,7 +6262,7 @@ dependencies = [ [[package]] name = "tranquil-lexicon" -version = "0.4.5" +version = "0.4.6" dependencies = [ "chrono", "hickory-resolver", @@ -6280,7 +6280,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "axum", @@ -6303,7 +6303,7 @@ dependencies = [ [[package]] name = "tranquil-oauth-server" -version = "0.4.5" +version = "0.4.6" dependencies = [ "axum", "base64 0.22.1", @@ -6336,7 +6336,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.4.5" +version = "0.4.6" dependencies = [ "aes-gcm", "anyhow", @@ -6424,7 +6424,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.4.5" +version = "0.4.6" dependencies = [ "bytes", "cid", @@ -6436,7 +6436,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "backon", @@ -6461,7 +6461,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.4.5" +version = "0.4.6" dependencies = [ "axum", "futures", @@ -6477,7 +6477,7 @@ dependencies = [ [[package]] name = "tranquil-server" -version = "0.4.5" +version = "0.4.6" dependencies = [ "axum", "clap", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "tranquil-storage" -version = "0.4.5" +version = "0.4.6" dependencies = [ "async-trait", "aws-config", @@ -6514,7 +6514,7 @@ dependencies = [ [[package]] name = "tranquil-sync" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "axum", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.4.5" +version = "0.4.6" dependencies = [ "chrono", "cid", diff --git a/Cargo.toml b/Cargo.toml index c455706..1f6e52f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ ] [workspace.package] -version = "0.4.5" +version = "0.4.6" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/crates/tranquil-api/src/common.rs b/crates/tranquil-api/src/common.rs new file mode 100644 index 0000000..8666307 --- /dev/null +++ b/crates/tranquil-api/src/common.rs @@ -0,0 +1,262 @@ +use bcrypt::DEFAULT_COST; +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use tracing::error; +use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository}; +use tranquil_pds::api::error::ApiError; +use tranquil_pds::api::error::DbResultExt; +use tranquil_pds::types::{AtIdentifier, Did, Handle}; + +pub struct ResolvedRepo { + pub user_id: uuid::Uuid, + pub did: Did, + pub handle: Handle, +} + +fn qualify_handle(handle: &Handle) -> Result { + let raw = handle.as_str(); + let qualified = match raw.contains('.') { + true => return Ok(handle.clone()), + false => format!( + "{}.{}", + raw, + tranquil_config::get().server.hostname_without_port() + ), + }; + qualified + .parse() + .map_err(|_| ApiError::InvalidRequest("Invalid handle format".into())) +} + +pub async fn resolve_repo( + user_repo: &dyn UserRepository, + repo: &AtIdentifier, +) -> Result { + let row = match repo { + AtIdentifier::Did(did) => user_repo + .get_by_did(did) + .await + .log_db_err("resolving repo by DID")?, + AtIdentifier::Handle(handle) => { + let qualified = qualify_handle(handle)?; + user_repo + .get_by_handle(&qualified) + .await + .log_db_err("resolving repo by handle")? + } + }; + row.map(|r| ResolvedRepo { + user_id: r.id, + did: r.did, + handle: r.handle, + }) + .ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) +} + +pub async fn resolve_repo_user_id( + user_repo: &dyn UserRepository, + repo: &AtIdentifier, +) -> Result { + let id = match repo { + AtIdentifier::Did(did) => user_repo + .get_id_by_did(did) + .await + .log_db_err("resolving repo user ID by DID")?, + AtIdentifier::Handle(handle) => { + let qualified = qualify_handle(handle)?; + user_repo + .get_id_by_handle(&qualified) + .await + .log_db_err("resolving repo user ID by handle")? + } + }; + id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into()))) +} + +pub fn group_invite_uses_by_code( + uses: Vec, + map_use: F, +) -> HashMap> +where + F: Fn(tranquil_db_traits::InviteCodeUse) -> U, +{ + uses.into_iter().fold(HashMap::new(), |mut acc, u| { + let code = u.code.clone(); + acc.entry(code).or_default().push(map_use(u)); + acc + }) +} + +pub fn resolve_also_known_as( + overrides: Option<&DidWebOverrides>, + current_handle: &str, +) -> Vec { + overrides + .filter(|ovr| !ovr.also_known_as.is_empty()) + .map(|ovr| ovr.also_known_as.clone()) + .unwrap_or_else(|| vec![format!("at://{}", current_handle)]) +} + +pub fn build_did_document( + did: &str, + also_known_as: Vec, + verification_methods: Vec, + service_endpoint: &str, +) -> serde_json::Value { + serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1" + ], + "id": did, + "alsoKnownAs": also_known_as, + "verificationMethod": verification_methods, + "service": [{ + "id": "#atproto_pds", + "type": tranquil_pds::plc::ServiceType::Pds.as_str(), + "serviceEndpoint": service_endpoint + }] + }) +} + +pub async fn set_channel_verified_flag( + user_repo: &dyn UserRepository, + user_id: uuid::Uuid, + channel: CommsChannel, +) -> Result<(), ApiError> { + match channel { + CommsChannel::Email => user_repo + .set_email_verified_flag(user_id) + .await + .log_db_err("updating email verified status")?, + CommsChannel::Discord => user_repo + .set_discord_verified_flag(user_id) + .await + .log_db_err("updating discord verified status")?, + CommsChannel::Telegram => user_repo + .set_telegram_verified_flag(user_id) + .await + .log_db_err("updating telegram verified status")?, + CommsChannel::Signal => user_repo + .set_signal_verified_flag(user_id) + .await + .log_db_err("updating signal verified status")?, + }; + Ok(()) +} + +pub struct ChannelInput<'a> { + pub email: Option<&'a str>, + pub discord_username: Option<&'a str>, + pub telegram_username: Option<&'a str>, + pub signal_username: Option<&'a str>, +} + +pub fn extract_verification_recipient( + channel: CommsChannel, + input: &ChannelInput<'_>, +) -> Result { + match channel { + CommsChannel::Email => match input.email { + Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()), + _ => Err(ApiError::MissingEmail), + }, + CommsChannel::Discord => match input.discord_username { + Some(username) if !username.trim().is_empty() => { + let clean = username.trim().to_lowercase(); + if !tranquil_pds::api::validation::is_valid_discord_username(&clean) { + return Err(ApiError::InvalidRequest( + "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), + )); + } + Ok(clean) + } + _ => Err(ApiError::MissingDiscordId), + }, + CommsChannel::Telegram => match input.telegram_username { + Some(username) if !username.trim().is_empty() => { + let clean = username.trim().trim_start_matches('@'); + if !tranquil_pds::api::validation::is_valid_telegram_username(clean) { + return Err(ApiError::InvalidRequest( + "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), + )); + } + Ok(clean.to_string()) + } + _ => Err(ApiError::MissingTelegramUsername), + }, + CommsChannel::Signal => match input.signal_username { + Some(username) if !username.trim().is_empty() => { + Ok(username.trim().trim_start_matches('@').to_lowercase()) + } + _ => Err(ApiError::MissingSignalNumber), + }, + } +} + +pub fn create_self_hosted_did_web(handle: &str) -> Result { + if !tranquil_pds::util::is_self_hosted_did_web_enabled() { + return Err(ApiError::SelfHostedDidWebDisabled); + } + let encoded_handle = handle.replace(':', "%3A"); + Ok(format!("did:web:{}", encoded_handle)) +} + +pub enum CredentialMatch { + MainPassword, + AppPassword { + name: String, + scopes: Option, + controller_did: Option, + }, +} + +pub async fn verify_credential( + session_repo: &dyn SessionRepository, + user_id: uuid::Uuid, + password: &str, + password_hash: Option<&str>, +) -> Option { + let main_valid = password_hash + .map(|h| bcrypt::verify(password, h).unwrap_or(false)) + .unwrap_or(false); + if main_valid { + return Some(CredentialMatch::MainPassword); + } + let app_passwords = session_repo + .get_app_passwords_for_login(user_id) + .await + .unwrap_or_default(); + app_passwords + .into_iter() + .find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false)) + .map(|app| CredentialMatch::AppPassword { + name: app.name, + scopes: app.scopes, + controller_did: app.created_by_controller_did, + }) +} + +pub fn hash_or_internal_error(value: &str) -> Result { + bcrypt::hash(value, DEFAULT_COST).map_err(|e| { + error!("Bcrypt hash error: {:?}", e); + ApiError::InternalError(None) + }) +} + +pub fn validate_token_hash( + expires_at: Option>, + stored_hash: &str, + input_token: &str, + expired_err: ApiError, + invalid_err: ApiError, +) -> Result<(), ApiError> { + match expires_at { + Some(exp) if exp < Utc::now() => Err(expired_err), + _ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) { + true => Ok(()), + false => Err(invalid_err), + }, + } +} diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index eda0ebf..8dd2bf5 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -1,6 +1,7 @@ pub mod actor; pub mod admin; pub mod age_assurance; +pub mod common; pub mod delegation; pub mod discord_webhook; pub mod identity; @@ -10,7 +11,6 @@ pub mod repo; pub mod server; pub mod telegram_webhook; pub mod temp; -pub mod verification; use tranquil_pds::state::AppState; @@ -386,7 +386,7 @@ pub fn api_routes() -> axum::Router { ) .route( "/_account.confirmChannelVerification", - post(verification::confirm_channel_verification), + post(server::confirm_channel_verification), ) .route("/_account.verifyToken", post(server::verify_token)) .route( diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 73e908d..1842390 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -112,6 +112,12 @@ pub enum ApiError { SsoLinkNotFound, AuthFactorTokenRequired, LegacyLoginBlocked, + ReauthRequired { + methods: Vec, + }, + MfaVerificationRequiredWithMethods { + methods: Vec, + }, } impl ApiError { @@ -131,7 +137,8 @@ impl ApiError { | Self::InvalidPassword(_) | Self::InvalidToken(_) | Self::PasskeyCounterAnomaly - | Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED, + | Self::OAuthExpiredToken(_) + | Self::ReauthRequired { .. } => StatusCode::UNAUTHORIZED, Self::InvalidCode(_) => StatusCode::BAD_REQUEST, Self::ExpiredToken(_) => StatusCode::BAD_REQUEST, Self::Forbidden @@ -142,6 +149,7 @@ impl ApiError { | Self::AccountMigrated | Self::AccountNotVerified | Self::MfaVerificationRequired + | Self::MfaVerificationRequiredWithMethods { .. } | Self::AuthorizationError(_) => StatusCode::FORBIDDEN, Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS, Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, @@ -310,6 +318,10 @@ impl ApiError { Self::SsoLinkNotFound => Cow::Borrowed("SsoLinkNotFound"), Self::AuthFactorTokenRequired => Cow::Borrowed("AuthFactorTokenRequired"), Self::LegacyLoginBlocked => Cow::Borrowed("MfaRequired"), + Self::ReauthRequired { .. } => Cow::Borrowed("ReauthRequired"), + Self::MfaVerificationRequiredWithMethods { .. } => { + Cow::Borrowed("MfaVerificationRequired") + } } } fn message(&self) -> String { @@ -471,6 +483,12 @@ impl ApiError { Self::AuthFactorTokenRequired => { "A sign-in code has been sent to your email address".into() } + Self::ReauthRequired { .. } => { + "Re-authentication required for this action".into() + } + Self::MfaVerificationRequiredWithMethods { .. } => { + "This sensitive operation requires MFA verification".into() + } } } pub fn from_upstream_response(status: StatusCode, body: &[u8]) -> Self { @@ -499,6 +517,31 @@ impl ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { + match self { + Self::ReauthRequired { ref methods } => { + return ( + self.status_code(), + Json(serde_json::json!({ + "error": "ReauthRequired", + "message": "Re-authentication required for this action", + "reauthMethods": methods, + })), + ) + .into_response(); + } + Self::MfaVerificationRequiredWithMethods { ref methods } => { + return ( + self.status_code(), + Json(serde_json::json!({ + "error": "MfaVerificationRequired", + "message": "This sensitive operation requires MFA verification", + "reauthMethods": methods, + })), + ) + .into_response(); + } + _ => {} + } let body = ErrorBody { error: self.error_name(), message: self.message(), @@ -594,6 +637,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self { + Self::InsufficientScope(Some(e.to_string())) + } +} + impl From for ApiError { fn from(e: crate::handle::HandleResolutionError) -> Self { match e { diff --git a/crates/tranquil-pds/src/api/mod.rs b/crates/tranquil-pds/src/api/mod.rs index 6964179..10a8b52 100644 --- a/crates/tranquil-pds/src/api/mod.rs +++ b/crates/tranquil-pds/src/api/mod.rs @@ -7,6 +7,8 @@ pub mod validation; pub use error::ApiError; pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_limit}; pub use responses::{ - DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, - StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, + AccountsOutput, AuditLogOutput, ControllersOutput, DidResponse, EmailUpdateStatusOutput, + EmptyResponse, EnabledResponse, HasPasswordResponse, InUseOutput, OptionsResponse, + PasswordResetOutput, PreferredLocaleOutput, PresetsOutput, StatusResponse, SuccessResponse, + TokenRequiredResponse, VerifiedResponse, }; diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index 90e090c..1744fbc 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -247,7 +247,7 @@ async fn proxy_handler( &resolved.did, method, ) { - return e; + return e.into_response(); } let key_bytes = match auth_user.key_bytes { diff --git a/crates/tranquil-pds/src/api/responses.rs b/crates/tranquil-pds/src/api/responses.rs index 860c655..b186cb5 100644 --- a/crates/tranquil-pds/src/api/responses.rs +++ b/crates/tranquil-pds/src/api/responses.rs @@ -116,3 +116,60 @@ impl OptionsResponse { Json(Self { options }) } } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountsOutput { + pub accounts: T, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditLogOutput { + pub entries: T, + pub total: i64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ControllersOutput { + pub controllers: T, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PresetsOutput { + pub presets: T, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmailUpdateStatusOutput { + pub pending: bool, + pub authorized: bool, + pub new_email: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InUseOutput { + pub in_use: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordResetOutput { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub multiple_accounts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreferredLocaleOutput { + pub preferred_locale: Option, +} diff --git a/crates/tranquil-pds/src/auth/account_verified.rs b/crates/tranquil-pds/src/auth/account_verified.rs index 0d0dd08..4782753 100644 --- a/crates/tranquil-pds/src/auth/account_verified.rs +++ b/crates/tranquil-pds/src/auth/account_verified.rs @@ -1,5 +1,3 @@ -use axum::response::{IntoResponse, Response}; - use super::AuthenticatedUser; use crate::api::error::ApiError; use crate::state::AppState; @@ -22,7 +20,7 @@ impl<'a> AccountVerified<'a> { pub async fn require_verified_or_delegated<'a>( state: &AppState, user: &'a AuthenticatedUser, -) -> Result, Response> { +) -> Result, ApiError> { let is_verified = state .user_repo .has_verified_comms_channel(&user.did) @@ -43,19 +41,18 @@ pub async fn require_verified_or_delegated<'a>( return Ok(AccountVerified { user }); } - Err(ApiError::AccountNotVerified.into_response()) + Err(ApiError::AccountNotVerified) } -pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), Response> { +pub async fn require_not_migrated(state: &AppState, did: &Did) -> Result<(), ApiError> { match state.user_repo.is_account_migrated(did).await { - Ok(true) => Err(ApiError::AccountMigrated.into_response()), + Ok(true) => Err(ApiError::AccountMigrated), Ok(false) => Ok(()), Err(e) => { tracing::error!("Failed to check migration status: {:?}", e); - Err( - ApiError::InternalError(Some("Failed to verify migration status".into())) - .into_response(), - ) + Err(ApiError::InternalError(Some( + "Failed to verify migration status".into(), + ))) } } } diff --git a/crates/tranquil-pds/src/auth/extractor.rs b/crates/tranquil-pds/src/auth/extractor.rs index 415cf48..c6f9dee 100644 --- a/crates/tranquil-pds/src/auth/extractor.rs +++ b/crates/tranquil-pds/src/auth/extractor.rs @@ -12,7 +12,7 @@ use super::{ is_service_token, scope_verified::VerifyScope, validate_bearer_token_for_service_auth, }; use crate::api::error::ApiError; -use crate::oauth::scopes::{RepoAction, ScopePermissions}; +use crate::oauth::scopes::{AccountAction, AccountAttr, RepoAction, ScopePermissions}; use crate::state::AppState; use crate::types::Did; use crate::util::build_full_url; @@ -130,6 +130,12 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option Option { + let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?; + let token = extract_bearer_token_from_header(Some(auth_header))?; + tranquil_auth::get_jti_from_token(&token).ok() +} + pub trait AuthPolicy: Send + Sync + 'static { fn validate(user: &AuthenticatedUser) -> Result<(), AuthError>; } @@ -356,14 +362,26 @@ impl Auth

{ self.0.permissions() } - #[allow(clippy::result_large_err)] - pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> { + pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), ApiError> { if !self.needs_scope_check() { return Ok(()); } self.permissions() .assert_repo(action, collection) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) + } + + pub fn check_account_scope( + &self, + attr: AccountAttr, + action: AccountAction, + ) -> Result<(), ApiError> { + if !self.needs_scope_check() { + return Ok(()); + } + self.permissions() + .assert_account(attr, action) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) } } diff --git a/crates/tranquil-pds/src/auth/mfa_verified.rs b/crates/tranquil-pds/src/auth/mfa_verified.rs index 8d0ee1e..47a13d4 100644 --- a/crates/tranquil-pds/src/auth/mfa_verified.rs +++ b/crates/tranquil-pds/src/auth/mfa_verified.rs @@ -1,4 +1,4 @@ -use axum::response::Response; +use crate::api::error::ApiError; use super::AuthenticatedUser; use crate::state::AppState; @@ -73,21 +73,29 @@ impl<'a> MfaVerified<'a> { pub async fn require_legacy_session_mfa<'a>( state: &AppState, user: &'a AuthenticatedUser, -) -> Result, Response> { - use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response}; +) -> Result, ApiError> { + use crate::auth::reauth::check_legacy_session_mfa; if check_legacy_session_mfa(&*state.session_repo, &user.did).await { Ok(MfaVerified::from_session_reauth(user)) } else { - Err(legacy_mfa_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + let methods = crate::auth::reauth::get_available_reauth_methods( + &*state.user_repo, + &*state.session_repo, + &user.did, + ) + .await; + Err(ApiError::MfaVerificationRequiredWithMethods { + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), + }) } } pub async fn require_reauth_window<'a>( state: &AppState, user: &'a AuthenticatedUser, -) -> Result, Response> { - use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response}; +) -> Result, ApiError> { + use crate::auth::reauth::REAUTH_WINDOW_SECONDS; use chrono::Utc; let status = state @@ -105,10 +113,26 @@ pub async fn require_reauth_window<'a>( return Ok(MfaVerified::from_session_reauth(user)); } } - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + let methods = crate::auth::reauth::get_available_reauth_methods( + &*state.user_repo, + &*state.session_repo, + &user.did, + ) + .await; + Err(ApiError::ReauthRequired { + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), + }) } None => { - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + let methods = crate::auth::reauth::get_available_reauth_methods( + &*state.user_repo, + &*state.session_repo, + &user.did, + ) + .await; + Err(ApiError::ReauthRequired { + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), + }) } } } @@ -116,8 +140,8 @@ pub async fn require_reauth_window<'a>( pub async fn require_reauth_window_if_available<'a>( state: &AppState, user: &'a AuthenticatedUser, -) -> Result>, Response> { - use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response}; +) -> Result>, ApiError> { + use crate::auth::reauth::check_reauth_required_cached; let has_password = state .user_repo @@ -144,7 +168,15 @@ pub async fn require_reauth_window_if_available<'a>( } if check_reauth_required_cached(&*state.session_repo, &state.cache, &user.did).await { - Err(reauth_required_response(&*state.user_repo, &*state.session_repo, &user.did).await) + let methods = crate::auth::reauth::get_available_reauth_methods( + &*state.user_repo, + &*state.session_repo, + &user.did, + ) + .await; + Err(ApiError::ReauthRequired { + methods: methods.iter().map(|m| m.as_str().to_string()).collect(), + }) } else { Ok(Some(MfaVerified::from_session_reauth(user))) } diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index 22c2bd4..aff685e 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -29,7 +29,7 @@ pub use account_verified::{AccountVerified, require_not_migrated, require_verifi pub use extractor::{ Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, AuthScheme, ExtractedToken, NotTakendown, Permissive, ServiceAuth, extract_auth_token_from_header, - extract_bearer_token_from_header, + extract_bearer_token_from_header, extract_jti_from_headers, }; pub use mfa_verified::{ MfaMethod, MfaVerified, require_legacy_session_mfa, require_reauth_window, diff --git a/crates/tranquil-pds/src/auth/reauth.rs b/crates/tranquil-pds/src/auth/reauth.rs index a581ff4..1da628b 100644 --- a/crates/tranquil-pds/src/auth/reauth.rs +++ b/crates/tranquil-pds/src/auth/reauth.rs @@ -17,6 +17,16 @@ pub enum ReauthMethod { Passkey, } +impl ReauthMethod { + pub fn as_str(&self) -> &'static str { + match self { + Self::Password => "password", + Self::Totp => "totp", + Self::Passkey => "passkey", + } + } +} + fn is_reauth_required(last_reauth_at: Option>) -> bool { match last_reauth_at { None => true, @@ -27,7 +37,7 @@ fn is_reauth_required(last_reauth_at: Option>) -> bool { } } -async fn get_available_reauth_methods( +pub async fn get_available_reauth_methods( user_repo: &dyn UserRepository, _session_repo: &dyn SessionRepository, did: &crate::types::Did, diff --git a/crates/tranquil-pds/src/auth/scope_check.rs b/crates/tranquil-pds/src/auth/scope_check.rs index 445c214..f9a14c0 100644 --- a/crates/tranquil-pds/src/auth/scope_check.rs +++ b/crates/tranquil-pds/src/auth/scope_check.rs @@ -1,7 +1,3 @@ -#![allow(clippy::result_large_err)] - -use axum::response::{IntoResponse, Response}; - use crate::api::error::ApiError; use crate::oauth::scopes::{ AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, @@ -24,7 +20,7 @@ pub fn check_repo_scope( scope: Option<&str>, action: RepoAction, collection: &str, -) -> Result<(), Response> { +) -> Result<(), ApiError> { if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -32,14 +28,14 @@ pub fn check_repo_scope( let permissions = ScopePermissions::from_scope_string(scope); permissions .assert_repo(action, collection) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) } pub fn check_blob_scope( auth_source: &AuthSource, scope: Option<&str>, mime: &str, -) -> Result<(), Response> { +) -> Result<(), ApiError> { if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -47,7 +43,7 @@ pub fn check_blob_scope( let permissions = ScopePermissions::from_scope_string(scope); permissions .assert_blob(mime) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) } pub fn check_rpc_scope( @@ -55,7 +51,7 @@ pub fn check_rpc_scope( scope: Option<&str>, aud: &str, lxm: &str, -) -> Result<(), Response> { +) -> Result<(), ApiError> { if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -63,7 +59,7 @@ pub fn check_rpc_scope( let permissions = ScopePermissions::from_scope_string(scope); permissions .assert_rpc(aud, lxm) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) } pub fn check_account_scope( @@ -71,7 +67,7 @@ pub fn check_account_scope( scope: Option<&str>, attr: AccountAttr, action: AccountAction, -) -> Result<(), Response> { +) -> Result<(), ApiError> { if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -79,14 +75,14 @@ pub fn check_account_scope( let permissions = ScopePermissions::from_scope_string(scope); permissions .assert_account(attr, action) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) } pub fn check_identity_scope( auth_source: &AuthSource, scope: Option<&str>, attr: IdentityAttr, -) -> Result<(), Response> { +) -> Result<(), ApiError> { if !requires_scope_check(auth_source, scope) { return Ok(()); } @@ -94,5 +90,5 @@ pub fn check_identity_scope( let permissions = ScopePermissions::from_scope_string(scope); permissions .assert_identity(attr) - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string()))) }