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

This commit is contained in:
Lewis
2026-03-20 13:39:11 +00:00
committed by Tangled
parent 5c8894d531
commit 4d86f026df
14 changed files with 491 additions and 68 deletions
Generated
+20 -20
View File
@@ -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",
+1 -1
View File
@@ -24,7 +24,7 @@ members = [
]
[workspace.package]
version = "0.4.5"
version = "0.4.6"
edition = "2024"
license = "AGPL-3.0-or-later"
+262
View File
@@ -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<Handle, ApiError> {
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<ResolvedRepo, ApiError> {
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<uuid::Uuid, ApiError> {
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<U, F>(
uses: Vec<tranquil_db_traits::InviteCodeUse>,
map_use: F,
) -> HashMap<String, Vec<U>>
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<String> {
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<String>,
verification_methods: Vec<serde_json::Value>,
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<String, ApiError> {
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<String, ApiError> {
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<String>,
controller_did: Option<Did>,
},
}
pub async fn verify_credential(
session_repo: &dyn SessionRepository,
user_id: uuid::Uuid,
password: &str,
password_hash: Option<&str>,
) -> Option<CredentialMatch> {
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<String, ApiError> {
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
error!("Bcrypt hash error: {:?}", e);
ApiError::InternalError(None)
})
}
pub fn validate_token_hash(
expires_at: Option<DateTime<Utc>>,
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),
},
}
}
+2 -2
View File
@@ -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<AppState> {
)
.route(
"/_account.confirmChannelVerification",
post(verification::confirm_channel_verification),
post(server::confirm_channel_verification),
)
.route("/_account.verifyToken", post(server::verify_token))
.route(
+50 -1
View File
@@ -112,6 +112,12 @@ pub enum ApiError {
SsoLinkNotFound,
AuthFactorTokenRequired,
LegacyLoginBlocked,
ReauthRequired {
methods: Vec<String>,
},
MfaVerificationRequiredWithMethods {
methods: Vec<String>,
},
}
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<crate::auth::extractor::AuthError> for ApiError {
}
}
impl From<crate::auth::scope_verified::ScopeVerificationError> for ApiError {
fn from(e: crate::auth::scope_verified::ScopeVerificationError) -> Self {
Self::InsufficientScope(Some(e.to_string()))
}
}
impl From<crate::handle::HandleResolutionError> for ApiError {
fn from(e: crate::handle::HandleResolutionError) -> Self {
match e {
+4 -2
View File
@@ -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,
};
+1 -1
View File
@@ -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 {
+57
View File
@@ -116,3 +116,60 @@ impl<T: Serialize> OptionsResponse<T> {
Json(Self { options })
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountsOutput<T: Serialize> {
pub accounts: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuditLogOutput<T: Serialize> {
pub entries: T,
pub total: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllersOutput<T: Serialize> {
pub controllers: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PresetsOutput<T: Serialize> {
pub presets: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmailUpdateStatusOutput {
pub pending: bool,
pub authorized: bool,
pub new_email: Option<String>,
}
#[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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub account_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreferredLocaleOutput {
pub preferred_locale: Option<String>,
}
@@ -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<AccountVerified<'a>, Response> {
) -> Result<AccountVerified<'a>, 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(),
)))
}
}
}
+22 -4
View File
@@ -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<Extra
None
}
pub fn extract_jti_from_headers(headers: &axum::http::HeaderMap) -> Option<String> {
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<P: AuthPolicy> Auth<P> {
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())))
}
}
+43 -11
View File
@@ -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<MfaVerified<'a>, Response> {
use crate::auth::reauth::{check_legacy_session_mfa, legacy_mfa_required_response};
) -> Result<MfaVerified<'a>, 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<MfaVerified<'a>, Response> {
use crate::auth::reauth::{REAUTH_WINDOW_SECONDS, reauth_required_response};
) -> Result<MfaVerified<'a>, 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<Option<MfaVerified<'a>>, Response> {
use crate::auth::reauth::{check_reauth_required_cached, reauth_required_response};
) -> Result<Option<MfaVerified<'a>>, 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)))
}
+1 -1
View File
@@ -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,
+11 -1
View File
@@ -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<chrono::DateTime<Utc>>) -> bool {
match last_reauth_at {
None => true,
@@ -27,7 +37,7 @@ fn is_reauth_required(last_reauth_at: Option<chrono::DateTime<Utc>>) -> 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,
+10 -14
View File
@@ -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())))
}