use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; use serde_json::json; use tranquil_db_traits::{AppPasswordCreate, AppPasswordUpdate}; use tranquil_pds::api::EmptyResponse; use tranquil_pds::api::error::{ApiError, DbResultExt}; use tranquil_pds::auth::{Auth, NotTakendown, Permissive, generate_app_password}; use tranquil_pds::delegation::{DelegationActionType, intersect_scopes}; use tranquil_pds::rate_limit::{AppPasswordLimit, RateLimited}; use tranquil_pds::state::AppState; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AppPassword { pub name: String, pub created_at: String, pub privileged: bool, #[serde(skip_serializing_if = "Option::is_none")] pub scopes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_by_controller: Option, } #[derive(Serialize)] pub struct ListAppPasswordsOutput { pub passwords: Vec, } pub async fn list_app_passwords( State(state): State, auth: Auth, ) -> Result, ApiError> { let user = state .repos .user .get_by_did(&auth.did) .await .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let rows = state .repos .session .list_app_passwords(user.id) .await .log_db_err("listing app passwords")?; let passwords: Vec = rows .iter() .map(|row| AppPassword { name: row.name.clone(), created_at: row.created_at.to_rfc3339(), privileged: row.privilege.is_privileged(), scopes: row.scopes.clone(), created_by_controller: row .created_by_controller_did .as_ref() .map(|d| d.to_string()), }) .collect(); Ok(Json(ListAppPasswordsOutput { passwords })) } #[derive(Deserialize)] pub struct CreateAppPasswordInput { pub name: String, pub privileged: Option, pub scopes: Option, } fn with_required_atproto_scope(scopes: &str) -> String { std::iter::once("atproto") .chain( scopes .split_whitespace() .filter(|scope| *scope != "atproto"), ) .collect::>() .join(" ") } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateAppPasswordOutput { pub name: String, pub password: String, pub created_at: String, pub privileged: bool, #[serde(skip_serializing_if = "Option::is_none")] pub scopes: Option, } pub async fn create_app_password( State(state): State, _rate_limit: RateLimited, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let user = state .repos .user .get_by_did(&auth.did) .await .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let name = input.name.trim(); if name.is_empty() { return Err(ApiError::InvalidRequest("name is required".into())); } if state .repos .session .get_app_password_by_name(user.id, name) .await .log_db_err("checking app password")? .is_some() { return Err(ApiError::DuplicateAppPassword); } let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did { let grant = state .repos .delegation .get_delegation(&auth.did, controller) .await .ok() .flatten(); let granted_scopes = match grant { Some(g) => g.granted_scopes, None => return Err(ApiError::InsufficientScope(None)), }; let requested = with_required_atproto_scope(input.scopes.as_deref().unwrap_or_default()); let intersected = intersect_scopes(&requested, granted_scopes.as_str()); if intersected.is_empty() && !granted_scopes.is_empty() { return Err(ApiError::InsufficientScope(None)); } let scope_result = if intersected.is_empty() { None } else { Some(intersected) }; (scope_result, Some(controller.clone())) } else { let scopes = match input.scopes { Some(ref s) => s.clone(), None => match input.privileged { Some(false) => "transition:generic".to_string(), _ => "transition:generic transition:chat.bsky".to_string(), }, }; (Some(with_required_atproto_scope(&scopes)), None) }; let password = generate_app_password(); let password_hash = crate::common::hash_password_async(&password).await?; let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag( input.privileged.unwrap_or(false), ); let created_at = chrono::Utc::now(); let create_data = AppPasswordCreate { user_id: user.id, name: name.to_string(), password_hash, privilege, scopes: final_scopes.clone(), created_by_controller_did: controller_did.clone(), }; state .repos .session .create_app_password(&create_data) .await .log_db_err("creating app password")?; if let Some(ref controller) = controller_did { let _ = state .repos .delegation .log_delegation_action( &auth.did, controller, Some(controller), DelegationActionType::AccountAction, Some(json!({ "action": "create_app_password", "name": name, "scopes": final_scopes })), None, None, ) .await; } Ok(Json(CreateAppPasswordOutput { name: name.to_string(), password: password.into_inner(), created_at: created_at.to_rfc3339(), privileged: privilege.is_privileged(), scopes: final_scopes, })) } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateAppPasswordInput { pub current_name: String, pub name: String, pub scopes: Option, } pub async fn update_app_password( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let user = state .repos .user .get_by_did(&auth.did) .await .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let current_name = input.current_name.trim(); if current_name.is_empty() { return Err(ApiError::InvalidRequest("currentName is required".into())); } let name = input.name.trim(); if name.is_empty() { return Err(ApiError::InvalidRequest("name is required".into())); } let existing = state .repos .session .get_app_password_by_name(user.id, current_name) .await .log_db_err("getting app password")? .ok_or(ApiError::AppPasswordNotFound)?; if let Some(ref controller) = auth.controller_did && existing.created_by_controller_did.as_ref() != Some(controller) { return Err(ApiError::Forbidden); } if name != current_name && state .repos .session .get_app_password_by_name(user.id, name) .await .log_db_err("checking app password rename")? .is_some() { return Err(ApiError::DuplicateAppPassword); } let final_scopes = if let Some(ref controller) = auth.controller_did { let grant = state .repos .delegation .get_delegation(&auth.did, controller) .await .ok() .flatten(); let granted_scopes = match grant { Some(g) => g.granted_scopes, None => return Err(ApiError::InsufficientScope(None)), }; let requested = match input.scopes.as_deref() { Some(scopes) => with_required_atproto_scope(scopes), None => with_required_atproto_scope(granted_scopes.as_str()), }; let intersected = intersect_scopes(&requested, granted_scopes.as_str()); if intersected.is_empty() && !granted_scopes.is_empty() { return Err(ApiError::InsufficientScope(None)); } if intersected.is_empty() { None } else { Some(intersected) } } else { let scopes = input .scopes .as_deref() .unwrap_or("transition:generic transition:chat.bsky"); Some(with_required_atproto_scope(scopes)) }; let sessions_to_invalidate = state .repos .session .get_session_jtis_by_app_password(&auth.did, current_name) .await .log_db_err("getting sessions for app password update")?; state .repos .session .delete_sessions_by_app_password(&auth.did, current_name) .await .log_db_err("revoking sessions for app password update")?; futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, jti); let cache = state.cache.clone(); async move { let _ = cache.delete(&cache_key).await; } })) .await; let update = AppPasswordUpdate { user_id: user.id, current_name: current_name.to_string(), name: name.to_string(), scopes: final_scopes.clone(), }; let rows_affected = state .repos .session .update_app_password(&update) .await .log_db_err("updating app password")?; if rows_affected == 0 { return Err(ApiError::AppPasswordNotFound); } if let Some(ref controller) = auth.controller_did { let _ = state .repos .delegation .log_delegation_action( &auth.did, controller, Some(controller), DelegationActionType::AccountAction, Some(json!({ "action": "update_app_password", "current_name": current_name, "name": name, "scopes": final_scopes })), None, None, ) .await; } Ok(Json(AppPassword { name: name.to_string(), created_at: existing.created_at.to_rfc3339(), privileged: existing.privilege.is_privileged(), scopes: final_scopes, created_by_controller: existing .created_by_controller_did .as_ref() .map(|did| did.to_string()), })) } #[derive(Deserialize)] pub struct RevokeAppPasswordInput { pub name: String, } pub async fn revoke_app_password( State(state): State, auth: Auth, Json(input): Json, ) -> Result, ApiError> { let user = state .repos .user .get_by_did(&auth.did) .await .log_db_err("getting user")? .ok_or(ApiError::AccountNotFound)?; let name = input.name.trim(); if name.is_empty() { return Err(ApiError::InvalidRequest("name is required".into())); } let sessions_to_invalidate = state .repos .session .get_session_jtis_by_app_password(&auth.did, name) .await .unwrap_or_default(); state .repos .session .delete_sessions_by_app_password(&auth.did, name) .await .log_db_err("revoking sessions for app password")?; futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, jti); let cache = state.cache.clone(); async move { let _ = cache.delete(&cache_key).await; } })) .await; state .repos .session .delete_app_password(user.id, name) .await .log_db_err("revoking app password")?; Ok(Json(EmptyResponse {})) }