Files
tranquil-pds/crates/tranquil-api/src/server/app_password.rs
T
2026-07-12 08:03:14 +02:00

256 lines
7.1 KiB
Rust

use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tranquil_db_traits::AppPasswordCreate;
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by_controller: Option<String>,
}
#[derive(Serialize)]
pub struct ListAppPasswordsOutput {
pub passwords: Vec<AppPassword>,
}
pub async fn list_app_passwords(
State(state): State<AppState>,
auth: Auth<Permissive>,
) -> Result<Json<ListAppPasswordsOutput>, 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<AppPassword> = 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<bool>,
pub scopes: Option<String>,
}
#[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<String>,
}
pub async fn create_app_password(
State(state): State<AppState>,
_rate_limit: RateLimited<AppPasswordLimit>,
auth: Auth<NotTakendown>,
Json(input): Json<CreateAppPasswordInput>,
) -> Result<Json<CreateAppPasswordOutput>, 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 = input.scopes.as_deref().unwrap_or("atproto");
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(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)]
pub struct RevokeAppPasswordInput {
pub name: String,
}
pub async fn revoke_app_password(
State(state): State<AppState>,
auth: Auth<Permissive>,
Json(input): Json<RevokeAppPasswordInput>,
) -> Result<Json<EmptyResponse>, 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 {}))
}