refactor(api): simplify passkey account creation and auth-adjacent server endpoints

This commit is contained in:
Lewis
2026-03-20 13:39:11 +00:00
committed by Tangled
parent 1e07d674dd
commit b337d2b154
6 changed files with 200 additions and 392 deletions
+7 -12
View File
@@ -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<AppState>,
auth: Auth<Permissive>,
) -> Result<Response, ApiError> {
) -> Result<Json<ListAppPasswordsOutput>, 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<AppPasswordLimit>,
auth: Auth<NotTakendown>,
Json(input): Json<CreateAppPasswordInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<CreateAppPasswordOutput>, 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<AppState>,
auth: Auth<Permissive>,
Json(input): Json<RevokeAppPasswordInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, 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 {}))
}
+7 -12
View File
@@ -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<AppState>,
auth: Auth<Admin>,
Json(input): Json<CreateInviteCodeInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<CreateInviteCodeOutput>, 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<AppState>,
auth: Auth<Admin>,
Json(input): Json<CreateInviteCodesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<CreateInviteCodesOutput>, 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<AppState>,
auth: Auth<NotTakendown>,
axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<GetAccountInviteCodesOutput>, 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<InviteCode> = codes.into_iter().flatten().collect();
Ok(Json(GetAccountInviteCodesOutput { codes }).into_response())
Ok(Json(GetAccountInviteCodesOutput { codes }))
}
+8 -17
View File
@@ -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<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdateDidDocumentInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<UpdateDidDocumentOutput>, 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<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<serde_json::Value>, 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 {
+152 -309
View File
@@ -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<AccountCreationLimit>,
headers: HeaderMap,
Json(input): Json<CreatePasskeyAccountInput>,
) -> Response {
) -> Result<Json<CreatePasskeyAccountOutput>, 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<u8>, Option<Uuid>) =
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<chrono::DateTime<Utc>> = 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<AppState>,
Json(input): Json<CompletePasskeySetupInput>,
) -> Response {
) -> Result<Json<CompletePasskeySetupOutput>, 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<AppState>,
Json(input): Json<StartPasskeyRegistrationInput>,
) -> Response {
) -> Result<Json<OptionsResponse<serde_json::Value>>, 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<AppState>,
_rate_limit: RateLimited<PasswordResetLimit>,
Json(input): Json<RequestPasskeyRecoveryInput>,
) -> Response {
) -> Result<Json<SuccessResponse>, 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<AppState>,
Json(input): Json<RecoverPasskeyAccountInput>,
) -> Response {
) -> Result<Json<SuccessResponse>, 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 }))
}
+18 -30
View File
@@ -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<AppState>,
auth: Auth<Active>,
Json(input): Json<StartRegistrationInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<StartRegistrationOutput>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<FinishRegistrationInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<FinishRegistrationOutput>, 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<PasskeyInfo>,
}
pub async fn list_passkeys(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<ListPasskeysOutput>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<DeletePasskeyInput>,
) -> Result<Response, ApiError> {
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<EmptyResponse>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdatePasskeyInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, 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) => {
@@ -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<TrustedDevice>,
}
pub async fn list_trusted_devices(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
) -> Result<Json<ListTrustedDevicesOutput>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<RevokeTrustedDeviceInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, 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<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdateTrustedDeviceInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, 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(