diff --git a/crates/tranquil-api/src/server/app_password.rs b/crates/tranquil-api/src/server/app_password.rs index f8760ac..675a4d2 100644 --- a/crates/tranquil-api/src/server/app_password.rs +++ b/crates/tranquil-api/src/server/app_password.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::error; @@ -34,7 +30,7 @@ pub struct ListAppPasswordsOutput { pub async fn list_app_passwords( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let user = state .user_repo .get_by_did(&auth.did) @@ -60,7 +56,7 @@ pub async fn list_app_passwords( .map(|d| d.to_string()), }) .collect(); - Ok(Json(ListAppPasswordsOutput { passwords }).into_response()) + Ok(Json(ListAppPasswordsOutput { passwords })) } #[derive(Deserialize)] @@ -86,7 +82,7 @@ pub async fn create_app_password( _rate_limit: RateLimited, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let user = state .user_repo .get_by_did(&auth.did) @@ -194,8 +190,7 @@ pub async fn create_app_password( created_at: created_at.to_rfc3339(), privileged: privilege.is_privileged(), scopes: final_scopes, - }) - .into_response()) + })) } #[derive(Deserialize)] @@ -207,7 +202,7 @@ pub async fn revoke_app_password( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let user = state .user_repo .get_by_did(&auth.did) @@ -247,5 +242,5 @@ pub async fn revoke_app_password( .await .log_db_err("revoking app password")?; - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } diff --git a/crates/tranquil-api/src/server/invite.rs b/crates/tranquil-api/src/server/invite.rs index b84919f..68d09c9 100644 --- a/crates/tranquil-api/src/server/invite.rs +++ b/crates/tranquil-api/src/server/invite.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use rand::Rng; use serde::{Deserialize, Serialize}; use tracing::error; @@ -46,7 +42,7 @@ pub async fn create_invite_code( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { if input.use_count < 1 { return Err(ApiError::InvalidRequest( "useCount must be at least 1".into(), @@ -66,7 +62,7 @@ pub async fn create_invite_code( .create_invite_code(&code, input.use_count, Some(&for_account)) .await { - Ok(true) => Ok(Json(CreateInviteCodeOutput { code }).into_response()), + Ok(true) => Ok(Json(CreateInviteCodeOutput { code })), Ok(false) => { error!("No admin user found to create invite code"); Err(ApiError::InternalError(None)) @@ -101,7 +97,7 @@ pub async fn create_invite_codes( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { if input.use_count < 1 { return Err(ApiError::InvalidRequest( "useCount must be at least 1".into(), @@ -147,8 +143,7 @@ pub async fn create_invite_codes( match result { Ok(result_codes) => Ok(Json(CreateInviteCodesOutput { codes: result_codes, - }) - .into_response()), + })), Err(e) => { error!("DB error creating invite codes: {:?}", e); Err(ApiError::InternalError(None)) @@ -193,7 +188,7 @@ pub async fn get_account_invite_codes( State(state): State, auth: Auth, axum::extract::Query(params): axum::extract::Query, -) -> Result { +) -> Result, ApiError> { let include_used = params.include_used.unwrap_or(true); let codes_info = state @@ -247,5 +242,5 @@ pub async fn get_account_invite_codes( .await; let codes: Vec = codes.into_iter().flatten().collect(); - Ok(Json(GetAccountInviteCodesOutput { codes }).into_response()) + Ok(Json(GetAccountInviteCodesOutput { codes })) } diff --git a/crates/tranquil-api/src/server/migration.rs b/crates/tranquil-api/src/server/migration.rs index 6d88d6a..6e54df2 100644 --- a/crates/tranquil-api/src/server/migration.rs +++ b/crates/tranquil-api/src/server/migration.rs @@ -1,9 +1,4 @@ -use axum::{ - Json, - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use serde_json::json; use tranquil_pds::api::ApiError; @@ -39,7 +34,7 @@ pub async fn update_did_document( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { if !auth.did.starts_with("did:web:") { return Err(ApiError::InvalidRequest( "DID document updates are only available for did:web accounts".into(), @@ -120,20 +115,16 @@ pub async fn update_did_document( tracing::info!("Updated DID document for {}", &auth.did); - Ok(( - StatusCode::OK, - Json(UpdateDidDocumentOutput { - success: true, - did_document: did_doc, - }), - ) - .into_response()) + Ok(Json(UpdateDidDocumentOutput { + success: true, + did_document: did_doc, + })) } pub async fn get_did_document( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { if !auth.did.starts_with("did:web:") { return Err(ApiError::InvalidRequest( "This endpoint is only available for did:web accounts".into(), @@ -142,7 +133,7 @@ pub async fn get_did_document( let did_doc = build_did_document(&state, &auth.did).await; - Ok((StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()) + Ok(Json(serde_json::json!({ "didDocument": did_doc }))) } async fn build_did_document(state: &AppState, did: &tranquil_pds::types::Did) -> serde_json::Value { diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index d57eafa..1766d5e 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -1,27 +1,17 @@ -use axum::{ - Json, - extract::State, - http::HeaderMap, - response::{IntoResponse, Response}, -}; -use bcrypt::{DEFAULT_COST, hash}; +use crate::common; +use axum::{Json, extract::State, http::HeaderMap}; use chrono::{Duration, Utc}; -use jacquard_common::types::{integer::LimitedU32, string::Tid}; -use jacquard_repo::{mst::Mst, storage::BlockStore}; use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::sync::Arc; use tracing::{debug, error, info, warn}; use tranquil_db_traits::WebauthnChallengeType; -use tranquil_pds::api::SuccessResponse; use tranquil_pds::api::error::ApiError; +use tranquil_pds::api::{OptionsResponse, SuccessResponse}; use tranquil_pds::auth::NormalizedLoginIdentifier; -use uuid::Uuid; use tranquil_pds::auth::{ServiceTokenVerifier, generate_app_password, is_service_token}; use tranquil_pds::rate_limit::{AccountCreationLimit, PasswordResetLimit, RateLimited}; -use tranquil_pds::repo_ops::create_signed_commit; use tranquil_pds::state::AppState; use tranquil_pds::types::{Did, Handle, PlainPassword}; use tranquil_pds::validation::validate_password; @@ -57,7 +47,7 @@ pub struct CreatePasskeyAccountInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct CreatePasskeyAccountResponse { +pub struct CreatePasskeyAccountOutput { pub did: Did, pub handle: Handle, pub setup_token: String, @@ -71,7 +61,7 @@ pub async fn create_passkey_account( _rate_limit: RateLimited, headers: HeaderMap, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let byod_auth = if let Some(extracted) = tranquil_pds::auth::extract_auth_token_from_header( tranquil_pds::util::get_header_str(&headers, http::header::AUTHORIZATION), ) { @@ -91,11 +81,10 @@ pub async fn create_passkey_account( } Err(e) => { error!("Service token verification failed: {:?}", e); - return ApiError::AuthenticationFailed(Some(format!( + return Err(ApiError::AuthenticationFailed(Some(format!( "Service token verification failed: {}", e - ))) - .into_response(); + )))); } } } else { @@ -116,7 +105,7 @@ pub async fn create_passkey_account( let hostname = &cfg.server.hostname; let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) { Ok(h) => h, - Err(_) => return ApiError::InvalidHandle(None).into_response(), + Err(_) => return Err(ApiError::InvalidHandle(None)), }; let email = input @@ -127,7 +116,7 @@ pub async fn create_passkey_account( if let Some(ref email) = email && !tranquil_pds::api::validation::is_valid_email(email) { - return ApiError::InvalidEmail.into_response(); + return Err(ApiError::InvalidEmail); } let is_bootstrap = state.bootstrap_invite_code.is_some() @@ -136,17 +125,17 @@ pub async fn create_passkey_account( let _validated_invite_code = if is_bootstrap { match input.invite_code.as_deref() { Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None, - _ => return ApiError::InvalidInviteCode.into_response(), + _ => return Err(ApiError::InvalidInviteCode), } } else if let Some(ref code) = input.invite_code { match state.infra_repo.validate_invite_code(code).await { Ok(validated) => Some(validated), - Err(_) => return ApiError::InvalidInviteCode.into_response(), + Err(_) => return Err(ApiError::InvalidInviteCode), } } else { let invite_required = tranquil_config::get().server.invite_code_required; if invite_required { - return ApiError::InviteCodeRequired.into_response(); + return Err(ApiError::InviteCodeRequired); } None }; @@ -154,85 +143,39 @@ pub async fn create_passkey_account( let verification_channel = input .verification_channel .unwrap_or(tranquil_db_traits::CommsChannel::Email); - let verification_recipient = match verification_channel { - tranquil_db_traits::CommsChannel::Email => match &email { - Some(e) if !e.is_empty() => e.clone(), - _ => return ApiError::MissingEmail.into_response(), - }, - tranquil_db_traits::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 ApiError::InvalidRequest( - "Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(), - ).into_response(); - } - clean - } - _ => return ApiError::MissingDiscordId.into_response(), - }, - tranquil_db_traits::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 ApiError::InvalidRequest( - "Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(), - ).into_response(); - } - clean.to_string() - } - _ => return ApiError::MissingTelegramUsername.into_response(), - }, - tranquil_db_traits::CommsChannel::Signal => match &input.signal_username { - Some(username) if !username.trim().is_empty() => { - username.trim().trim_start_matches('@').to_lowercase() - } - _ => return ApiError::MissingSignalNumber.into_response(), + let verification_recipient = match common::extract_verification_recipient( + verification_channel, + &common::ChannelInput { + email: email.as_deref(), + discord_username: input.discord_username.as_deref(), + telegram_username: input.telegram_username.as_deref(), + signal_username: input.signal_username.as_deref(), }, + ) { + Ok(r) => r, + Err(e) => return Err(e), }; - use k256::ecdsa::SigningKey; - use rand::rngs::OsRng; - let pds_endpoint = format!("https://{}", hostname); let did_type = input.did_type.as_deref().unwrap_or("plc"); - let (secret_key_bytes, reserved_key_id): (Vec, Option) = - if let Some(signing_key_did) = &input.signing_key { - match state - .infra_repo - .get_reserved_signing_key(signing_key_did) - .await - { - Ok(Some(reserved)) => (reserved.private_key_bytes, Some(reserved.id)), - Ok(None) => { - return ApiError::InvalidSigningKey.into_response(); - } - Err(e) => { - error!("Error looking up reserved signing key: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - } - } else { - let secret_key = k256::SecretKey::random(&mut OsRng); - (secret_key.to_bytes().to_vec(), None) + let key_result = + match crate::identity::provision::resolve_signing_key(&state, input.signing_key.as_deref()) + .await + { + Ok(k) => k, + Err(e) => return Err(e), }; - - let secret_key = match SigningKey::from_slice(&secret_key_bytes) { - Ok(k) => k, - Err(e) => { - error!("Error creating signing key: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let secret_key_bytes = key_result.secret_key_bytes; + let secret_key = key_result.signing_key; + let reserved_key_id = key_result.reserved_key_id; let did = match did_type { "web" => { - if !tranquil_pds::util::is_self_hosted_did_web_enabled() { - return ApiError::SelfHostedDidWebDisabled.into_response(); - } - let encoded_handle = handle.replace(':', "%3A"); - let self_hosted_did = format!("did:web:{}", encoded_handle); + let self_hosted_did = match common::create_self_hosted_did_web(&handle) { + Ok(d) => d, + Err(e) => return Err(e), + }; info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account"); self_hosted_did } @@ -240,25 +183,24 @@ pub async fn create_passkey_account( let d = match &input.did { Some(d) if !d.trim().is_empty() => d.trim(), _ => { - return ApiError::InvalidRequest( + return Err(ApiError::InvalidRequest( "External did:web requires the 'did' field to be provided".into(), - ) - .into_response(); + )); } }; if !d.starts_with("did:web:") { - return ApiError::InvalidDid("External DID must be a did:web".into()) - .into_response(); + return Err(ApiError::InvalidDid( + "External DID must be a did:web".into(), + )); } if is_byod_did_web { if let Some(ref auth_did) = byod_auth && d != auth_did.as_str() { - return ApiError::AuthorizationError(format!( + return Err(ApiError::AuthorizationError(format!( "Service token issuer {} does not match DID {}", auth_did, d - )) - .into_response(); + ))); } info!(did = %d, "Creating external did:web passkey account (BYOD key)"); } else { @@ -270,7 +212,7 @@ pub async fn create_passkey_account( ) .await { - return ApiError::InvalidDid(e.to_string()).into_response(); + return Err(ApiError::InvalidDid(e.to_string())); } info!(did = %d, "Creating external did:web passkey account (reserved key)"); } @@ -281,25 +223,22 @@ pub async fn create_passkey_account( if let Some(ref provided_did) = input.did { if provided_did.starts_with("did:plc:") { if provided_did != auth_did.as_str() { - return ApiError::AuthorizationError(format!( + return Err(ApiError::AuthorizationError(format!( "Service token issuer {} does not match DID {}", auth_did, provided_did - )) - .into_response(); + ))); } info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)"); provided_did.clone() } else { - return ApiError::InvalidRequest( + return Err(ApiError::InvalidRequest( "BYOD migration requires a did:plc or did:web DID".into(), - ) - .into_response(); + )); } } else { - return ApiError::InvalidRequest( + return Err(ApiError::InvalidRequest( "BYOD migration requires the 'did' field".into(), - ) - .into_response(); + )); } } else { let rotation_key = tranquil_config::get() @@ -317,10 +256,9 @@ pub async fn create_passkey_account( Ok(r) => r, Err(e) => { error!("Error creating PLC genesis operation: {:?}", e); - return ApiError::InternalError(Some( + return Err(ApiError::InternalError(Some( "Failed to create PLC operation".into(), - )) - .into_response(); + ))); } }; @@ -331,11 +269,10 @@ pub async fn create_passkey_account( .await { error!("Failed to submit PLC genesis operation: {:?}", e); - return ApiError::UpstreamErrorMsg(format!( + return Err(ApiError::UpstreamErrorMsg(format!( "Failed to register DID with PLC directory: {}", e - )) - .into_response(); + ))); } genesis_result.did } @@ -345,13 +282,7 @@ pub async fn create_passkey_account( info!(did = %did, handle = %handle, "Created DID for passkey-only account"); let setup_token = generate_setup_token(); - let setup_token_hash = match hash(&setup_token, DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Error hashing setup token: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let setup_token_hash = common::hash_or_internal_error(&setup_token)?; let setup_expires_at = Utc::now() + Duration::hours(1); let deactivated_at: Option> = if is_byod_did_web { @@ -360,43 +291,21 @@ pub async fn create_passkey_account( None }; - let encrypted_key_bytes = match tranquil_pds::config::encrypt_key(&secret_key_bytes) { - Ok(bytes) => bytes, - Err(e) => { - error!("Error encrypting signing key: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - - let mst = Mst::new(Arc::new(state.block_store.clone())); - let mst_root = match mst.persist().await { - Ok(c) => c, - Err(e) => { - error!("Error persisting MST: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let rev = Tid::now(LimitedU32::MIN); let did_typed: Did = match did.parse() { Ok(d) => d, - Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(), + Err(_) => return Err(ApiError::InternalError(Some("Invalid DID".into()))), }; - let (commit_bytes, _sig) = - match create_signed_commit(&did_typed, mst_root, rev.as_ref(), None, &secret_key) { - Ok(result) => result, - Err(e) => { - error!("Error creating genesis commit: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; - let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { - Ok(c) => c, - Err(e) => { - error!("Error saving genesis commit: {:?}", e); - return ApiError::InternalError(None).into_response(); - } + let repo = match crate::identity::provision::init_genesis_repo( + &state, + &did_typed, + &secret_key, + &secret_key_bytes, + ) + .await + { + Ok(r) => r, + Err(e) => return Err(e), }; - let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; let birthdate_pref = if tranquil_config::get().server.age_assurance_override { Some(json!({ @@ -409,39 +318,31 @@ pub async fn create_passkey_account( let handle_typed: Handle = match handle.parse() { Ok(h) => h, - Err(_) => return ApiError::InvalidHandle(None).into_response(), + Err(_) => return Err(ApiError::InvalidHandle(None)), }; + let repo_for_seq = repo.clone(); + let comms = crate::identity::provision::normalize_comms_usernames( + input.discord_username.as_deref(), + input.telegram_username.as_deref(), + input.signal_username.as_deref(), + ); let create_input = tranquil_db_traits::CreatePasskeyAccountInput { handle: handle_typed.clone(), email: email.clone().unwrap_or_default(), did: did_typed.clone(), preferred_comms_channel: verification_channel, - discord_username: input - .discord_username - .as_deref() - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()), - telegram_username: input - .telegram_username - .as_deref() - .map(|s| s.trim().trim_start_matches('@')) - .filter(|s| !s.is_empty()) - .map(String::from), - signal_username: input - .signal_username - .as_deref() - .map(|s| s.trim().trim_start_matches('@')) - .filter(|s| !s.is_empty()) - .map(|s| s.to_lowercase()), + discord_username: comms.discord, + telegram_username: comms.telegram, + signal_username: comms.signal, setup_token_hash, setup_expires_at, deactivated_at, - encrypted_key_bytes, + encrypted_key_bytes: repo.encrypted_key_bytes, encryption_version: tranquil_pds::config::ENCRYPTION_VERSION, reserved_key_id, - commit_cid: commit_cid.to_string(), - repo_rev: rev.as_ref().to_string(), - genesis_block_cids, + commit_cid: repo.commit_cid.to_string(), + repo_rev: repo.repo_rev.clone(), + genesis_block_cids: repo.genesis_block_cids, invite_code: if is_bootstrap { None } else { @@ -453,71 +354,37 @@ pub async fn create_passkey_account( let create_result = match state.user_repo.create_passkey_account(&create_input).await { Ok(r) => r, Err(tranquil_db_traits::CreateAccountError::HandleTaken) => { - return ApiError::HandleNotAvailable(None).into_response(); + return Err(ApiError::HandleNotAvailable(None)); } Err(tranquil_db_traits::CreateAccountError::EmailTaken) => { - return ApiError::EmailTaken.into_response(); + return Err(ApiError::EmailTaken); } Err(e) => { error!("Error creating passkey account: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; let user_id = create_result.user_id; if !is_byod_did_web { - if let Err(e) = - tranquil_pds::repo_ops::sequence_identity_event(&state, &did_typed, Some(&handle_typed)) - .await - { - warn!("Failed to sequence identity event for {}: {}", did, e); - } - if let Err(e) = tranquil_pds::repo_ops::sequence_account_event( + crate::identity::provision::sequence_new_account( &state, &did_typed, - tranquil_db_traits::AccountStatus::Active, + &handle_typed, + &repo_for_seq, + &handle, ) - .await - { - warn!("Failed to sequence account event for {}: {}", did, e); - } - let profile_record = serde_json::json!({ - "$type": "app.bsky.actor.profile", - "displayName": handle - }); - if let Err(e) = tranquil_pds::repo_ops::create_record_internal( - &state, - &did_typed, - &tranquil_pds::types::PROFILE_COLLECTION, - &tranquil_pds::types::PROFILE_RKEY, - &profile_record, - ) - .await - { - warn!("Failed to create default profile for {}: {}", did, e); - } + .await; } - let verification_token = tranquil_pds::auth::verification_token::generate_signup_token( + crate::identity::provision::enqueue_signup_verification( + &state, + user_id, &did_typed, verification_channel, &verification_recipient, - ); - let formatted_token = - tranquil_pds::auth::verification_token::format_token_for_display(&verification_token); - if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification( - state.user_repo.as_ref(), - state.infra_repo.as_ref(), - user_id, - verification_channel, - &verification_recipient, - &formatted_token, - hostname, ) - .await - { - warn!("Failed to enqueue signup verification: {:?}", e); - } + .await; info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion"); @@ -553,14 +420,13 @@ pub async fn create_passkey_account( None }; - Json(CreatePasskeyAccountResponse { + Ok(Json(CreatePasskeyAccountOutput { did: did.into(), handle: handle.into(), setup_token, setup_expires_at, access_jwt, - }) - .into_response() + })) } #[derive(Deserialize)] @@ -574,7 +440,7 @@ pub struct CompletePasskeySetupInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct CompletePasskeySetupResponse { +pub struct CompletePasskeySetupOutput { pub did: Did, pub handle: Handle, pub app_password: String, @@ -584,38 +450,36 @@ pub struct CompletePasskeySetupResponse { pub async fn complete_passkey_setup( State(state): State, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await { Ok(Some(u)) => u, Ok(None) => { - return ApiError::AccountNotFound.into_response(); + return Err(ApiError::AccountNotFound); } Err(e) => { error!("DB error: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; if user.password_required { - return ApiError::InvalidAccount.into_response(); + return Err(ApiError::InvalidAccount); } let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ApiError::SetupExpired.into_response(); + return Err(ApiError::SetupExpired); } }; - if let Some(expires_at) = user.recovery_token_expires_at - && expires_at < Utc::now() - { - return ApiError::SetupExpired.into_response(); - } - - if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { - return ApiError::InvalidToken(None).into_response(); - } + common::validate_token_hash( + user.recovery_token_expires_at, + token_hash, + &input.setup_token, + ApiError::SetupExpired, + ApiError::InvalidToken(None), + )?; let webauthn = &state.webauthn_config; @@ -628,15 +492,15 @@ pub async fn complete_passkey_setup( Ok(s) => s, Err(e) => { error!("Error deserializing registration state: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }, Ok(None) => { - return ApiError::NoChallengeInProgress.into_response(); + return Err(ApiError::NoChallengeInProgress); } Err(e) => { error!("Error loading registration state: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; @@ -645,7 +509,7 @@ pub async fn complete_passkey_setup( Ok(c) => c, Err(e) => { warn!("Failed to parse credential: {:?}", e); - return ApiError::InvalidCredential.into_response(); + return Err(ApiError::InvalidCredential); } }; @@ -653,7 +517,7 @@ pub async fn complete_passkey_setup( Ok(sk) => sk, Err(e) => { warn!("Passkey registration failed: {:?}", e); - return ApiError::RegistrationFailed.into_response(); + return Err(ApiError::RegistrationFailed); } }; @@ -662,7 +526,7 @@ pub async fn complete_passkey_setup( Ok(pk) => pk, Err(e) => { error!("Error serializing security key: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; if let Err(e) = state @@ -676,18 +540,12 @@ pub async fn complete_passkey_setup( .await { error!("Error saving passkey: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } let app_password = generate_app_password(); let app_password_name = "bsky.app".to_string(); - let password_hash = match hash(&app_password, DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Error hashing app password: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - }; + let password_hash = common::hash_or_internal_error(&app_password)?; let setup_input = tranquil_db_traits::CompletePasskeySetupInput { user_id: user.id, @@ -697,7 +555,7 @@ pub async fn complete_passkey_setup( }; if let Err(e) = state.user_repo.complete_passkey_setup(&setup_input).await { error!("Error completing passkey setup: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } let _ = state @@ -707,50 +565,47 @@ pub async fn complete_passkey_setup( info!(did = %input.did, "Passkey-only account setup completed"); - Json(CompletePasskeySetupResponse { + Ok(Json(CompletePasskeySetupOutput { did: input.did.clone(), handle: user.handle, app_password, app_password_name, - }) - .into_response() + })) } pub async fn start_passkey_registration_for_setup( State(state): State, Json(input): Json, -) -> Response { +) -> Result>, ApiError> { let user = match state.user_repo.get_user_for_passkey_setup(&input.did).await { Ok(Some(u)) => u, Ok(None) => { - return ApiError::AccountNotFound.into_response(); + return Err(ApiError::AccountNotFound); } Err(e) => { error!("DB error: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; if user.password_required { - return ApiError::InvalidAccount.into_response(); + return Err(ApiError::InvalidAccount); } let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ApiError::SetupExpired.into_response(); + return Err(ApiError::SetupExpired); } }; - if let Some(expires_at) = user.recovery_token_expires_at - && expires_at < Utc::now() - { - return ApiError::SetupExpired.into_response(); - } - - if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { - return ApiError::InvalidToken(None).into_response(); - } + common::validate_token_hash( + user.recovery_token_expires_at, + token_hash, + &input.setup_token, + ApiError::SetupExpired, + ApiError::InvalidToken(None), + )?; let webauthn = &state.webauthn_config; @@ -776,7 +631,7 @@ pub async fn start_passkey_registration_for_setup( Ok(result) => result, Err(e) => { error!("Failed to start passkey registration: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; @@ -784,7 +639,7 @@ pub async fn start_passkey_registration_for_setup( Ok(json) => json, Err(e) => { error!("Failed to serialize registration state: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; if let Err(e) = state @@ -793,11 +648,11 @@ pub async fn start_passkey_registration_for_setup( .await { error!("Failed to save registration state: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } let options = serde_json::to_value(&ccr).unwrap_or(json!({})); - Json(json!({"options": options})).into_response() + Ok(OptionsResponse::new(options)) } #[derive(Deserialize)] @@ -819,7 +674,7 @@ pub async fn request_passkey_recovery( State(state): State, _rate_limit: RateLimited, Json(input): Json, -) -> Response { +) -> Result, ApiError> { let hostname_for_handles = tranquil_config::get().server.hostname_without_port(); let identifier = input.email.trim().to_lowercase(); let identifier = identifier.strip_prefix('@').unwrap_or(&identifier); @@ -833,17 +688,12 @@ pub async fn request_passkey_recovery( { Ok(Some(u)) if !u.password_required => u, _ => { - return SuccessResponse::ok().into_response(); + return Ok(Json(SuccessResponse { success: true })); } }; let recovery_token = generate_setup_token(); - let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { - Ok(h) => h, - Err(_) => { - return ApiError::InternalError(None).into_response(); - } - }; + let recovery_token_hash = common::hash_or_internal_error(&recovery_token)?; let expires_at = Utc::now() + Duration::hours(1); if let Err(e) = state @@ -852,7 +702,7 @@ pub async fn request_passkey_recovery( .await { error!("Error updating recovery token: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } let hostname = &tranquil_config::get().server.hostname; @@ -873,7 +723,7 @@ pub async fn request_passkey_recovery( .await; info!(did = %user.did, "Passkey recovery requested"); - SuccessResponse::ok().into_response() + Ok(Json(SuccessResponse { success: true })) } #[derive(Deserialize)] @@ -887,41 +737,34 @@ pub struct RecoverPasskeyAccountInput { pub async fn recover_passkey_account( State(state): State, Json(input): Json, -) -> Response { +) -> Result, ApiError> { if let Err(e) = validate_password(&input.new_password) { - return ApiError::InvalidRequest(e.to_string()).into_response(); + return Err(ApiError::InvalidRequest(e.to_string())); } let user = match state.user_repo.get_user_for_recovery(&input.did).await { Ok(Some(u)) => u, _ => { - return ApiError::InvalidRecoveryLink.into_response(); + return Err(ApiError::InvalidRecoveryLink); } }; let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ApiError::InvalidRecoveryLink.into_response(); + return Err(ApiError::InvalidRecoveryLink); } }; - if let Some(expires_at) = user.recovery_token_expires_at - && expires_at < Utc::now() - { - return ApiError::RecoveryLinkExpired.into_response(); - } + common::validate_token_hash( + user.recovery_token_expires_at, + token_hash, + &input.recovery_token, + ApiError::RecoveryLinkExpired, + ApiError::InvalidRecoveryLink, + )?; - if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { - return ApiError::InvalidRecoveryLink.into_response(); - } - - let password_hash = match hash(&input.new_password, DEFAULT_COST) { - Ok(h) => h, - Err(_) => { - return ApiError::InternalError(None).into_response(); - } - }; + let password_hash = common::hash_or_internal_error(&input.new_password)?; let recover_input = tranquil_db_traits::RecoverPasskeyAccountInput { did: input.did.clone(), @@ -935,7 +778,7 @@ pub async fn recover_passkey_account( Ok(r) => r, Err(e) => { error!("Error recovering passkey account: {:?}", e); - return ApiError::InternalError(None).into_response(); + return Err(ApiError::InternalError(None)); } }; @@ -957,5 +800,5 @@ pub async fn recover_passkey_account( } } info!(did = %input.did, "Passkey-only account recovered with temporary password"); - SuccessResponse::ok().into_response() + Ok(Json(SuccessResponse { success: true })) } diff --git a/crates/tranquil-api/src/server/passkeys.rs b/crates/tranquil-api/src/server/passkeys.rs index aebf594..32c1e9b 100644 --- a/crates/tranquil-api/src/server/passkeys.rs +++ b/crates/tranquil-api/src/server/passkeys.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use tracing::{error, info, warn}; use tranquil_db_traits::WebauthnChallengeType; @@ -20,7 +16,7 @@ pub struct StartRegistrationInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct StartRegistrationResponse { +pub struct StartRegistrationOutput { pub options: serde_json::Value, } @@ -28,7 +24,7 @@ pub async fn start_passkey_registration( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let webauthn = &state.webauthn_config; let handle = state @@ -73,7 +69,7 @@ pub async fn start_passkey_registration( info!(did = %auth.did, "Passkey registration started"); - Ok(Json(StartRegistrationResponse { options }).into_response()) + Ok(Json(StartRegistrationOutput { options })) } #[derive(Deserialize)] @@ -85,7 +81,7 @@ pub struct FinishRegistrationInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct FinishRegistrationResponse { +pub struct FinishRegistrationOutput { pub id: String, pub credential_id: String, } @@ -94,7 +90,7 @@ pub async fn finish_passkey_registration( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let webauthn = &state.webauthn_config; let reg_state_json = state @@ -154,11 +150,10 @@ pub async fn finish_passkey_registration( info!(did = %auth.did, passkey_id = %passkey_id, "Passkey registered"); - Ok(Json(FinishRegistrationResponse { + Ok(Json(FinishRegistrationOutput { id: passkey_id.to_string(), credential_id: credential_id_base64, - }) - .into_response()) + })) } #[derive(Serialize)] @@ -173,14 +168,14 @@ pub struct PasskeyInfo { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct ListPasskeysResponse { +pub struct ListPasskeysOutput { pub passkeys: Vec, } pub async fn list_passkeys( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let passkeys = state .user_repo .get_passkeys_for_user(&auth.did) @@ -198,10 +193,9 @@ pub async fn list_passkeys( }) .collect(); - Ok(Json(ListPasskeysResponse { + Ok(Json(ListPasskeysOutput { passkeys: passkey_infos, - }) - .into_response()) + })) } #[derive(Deserialize)] @@ -214,23 +208,17 @@ pub async fn delete_passkey( State(state): State, auth: Auth, Json(input): Json, -) -> Result { - let session_mfa = match require_legacy_session_mfa(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; +) -> Result, ApiError> { + let session_mfa = require_legacy_session_mfa(&state, &auth).await?; - let reauth_mfa = match require_reauth_window(&state, &auth).await { - Ok(proof) => proof, - Err(response) => return Ok(response), - }; + let reauth_mfa = require_reauth_window(&state, &auth).await?; let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; match state.user_repo.delete_passkey(id, reauth_mfa.did()).await { Ok(true) => { info!(did = %session_mfa.did(), passkey_id = %id, "Passkey deleted"); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } Ok(false) => Err(ApiError::PasskeyNotFound), Err(e) => { @@ -251,7 +239,7 @@ pub async fn update_passkey( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { let id: uuid::Uuid = input.id.parse().map_err(|_| ApiError::InvalidId)?; match state @@ -261,7 +249,7 @@ pub async fn update_passkey( { Ok(true) => { info!(did = %auth.did, passkey_id = %id, "Passkey renamed"); - Ok(EmptyResponse::ok().into_response()) + Ok(Json(EmptyResponse {})) } Ok(false) => Err(ApiError::PasskeyNotFound), Err(e) => { diff --git a/crates/tranquil-api/src/server/trusted_devices.rs b/crates/tranquil-api/src/server/trusted_devices.rs index af0d62e..7528404 100644 --- a/crates/tranquil-api/src/server/trusted_devices.rs +++ b/crates/tranquil-api/src/server/trusted_devices.rs @@ -1,8 +1,4 @@ -use axum::{ - Json, - extract::State, - response::{IntoResponse, Response}, -}; +use axum::{Json, extract::State}; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use tracing::{error, info}; @@ -67,14 +63,14 @@ pub struct TrustedDevice { #[derive(Serialize)] #[serde(rename_all = "camelCase")] -pub struct ListTrustedDevicesResponse { +pub struct ListTrustedDevicesOutput { pub devices: Vec, } pub async fn list_trusted_devices( State(state): State, auth: Auth, -) -> Result { +) -> Result, ApiError> { let rows = state .oauth_repo .list_trusted_devices(&auth.did) @@ -97,7 +93,7 @@ pub async fn list_trusted_devices( }) .collect(); - Ok(Json(ListTrustedDevicesResponse { devices }).into_response()) + Ok(Json(ListTrustedDevicesOutput { devices })) } #[derive(Deserialize)] @@ -110,7 +106,7 @@ pub async fn revoke_trusted_device( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { match state .oauth_repo .device_belongs_to_user(&input.device_id, &auth.did) @@ -133,7 +129,7 @@ pub async fn revoke_trusted_device( .log_db_err("revoking device trust")?; info!(did = %&auth.did, device_id = %input.device_id, "Trusted device revoked"); - Ok(SuccessResponse::ok().into_response()) + Ok(Json(SuccessResponse { success: true })) } #[derive(Deserialize)] @@ -147,7 +143,7 @@ pub async fn update_trusted_device( State(state): State, auth: Auth, Json(input): Json, -) -> Result { +) -> Result, ApiError> { match state .oauth_repo .device_belongs_to_user(&input.device_id, &auth.did) @@ -170,7 +166,7 @@ pub async fn update_trusted_device( .log_db_err("updating device friendly name")?; info!(did = %auth.did, device_id = %input.device_id, "Trusted device updated"); - Ok(SuccessResponse::ok().into_response()) + Ok(Json(SuccessResponse { success: true })) } pub async fn get_device_trust_state(