forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
7.3 kB
251 lines
1use axum::{
2 Json,
3 extract::State,
4 response::{IntoResponse, Response},
5};
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use tracing::error;
9use tranquil_db_traits::AppPasswordCreate;
10use tranquil_pds::api::EmptyResponse;
11use tranquil_pds::api::error::{ApiError, DbResultExt};
12use tranquil_pds::auth::{Auth, NotTakendown, Permissive, generate_app_password};
13use tranquil_pds::delegation::{DelegationActionType, intersect_scopes};
14use tranquil_pds::rate_limit::{AppPasswordLimit, RateLimited};
15use tranquil_pds::state::AppState;
16
17#[derive(Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct AppPassword {
20 pub name: String,
21 pub created_at: String,
22 pub privileged: bool,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub scopes: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub created_by_controller: Option<String>,
27}
28
29#[derive(Serialize)]
30pub struct ListAppPasswordsOutput {
31 pub passwords: Vec<AppPassword>,
32}
33
34pub async fn list_app_passwords(
35 State(state): State<AppState>,
36 auth: Auth<Permissive>,
37) -> Result<Response, ApiError> {
38 let user = state
39 .user_repo
40 .get_by_did(&auth.did)
41 .await
42 .log_db_err("getting user")?
43 .ok_or(ApiError::AccountNotFound)?;
44
45 let rows = state
46 .session_repo
47 .list_app_passwords(user.id)
48 .await
49 .log_db_err("listing app passwords")?;
50 let passwords: Vec<AppPassword> = rows
51 .iter()
52 .map(|row| AppPassword {
53 name: row.name.clone(),
54 created_at: row.created_at.to_rfc3339(),
55 privileged: row.privilege.is_privileged(),
56 scopes: row.scopes.clone(),
57 created_by_controller: row
58 .created_by_controller_did
59 .as_ref()
60 .map(|d| d.to_string()),
61 })
62 .collect();
63 Ok(Json(ListAppPasswordsOutput { passwords }).into_response())
64}
65
66#[derive(Deserialize)]
67pub struct CreateAppPasswordInput {
68 pub name: String,
69 pub privileged: Option<bool>,
70 pub scopes: Option<String>,
71}
72
73#[derive(Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct CreateAppPasswordOutput {
76 pub name: String,
77 pub password: String,
78 pub created_at: String,
79 pub privileged: bool,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub scopes: Option<String>,
82}
83
84pub async fn create_app_password(
85 State(state): State<AppState>,
86 _rate_limit: RateLimited<AppPasswordLimit>,
87 auth: Auth<NotTakendown>,
88 Json(input): Json<CreateAppPasswordInput>,
89) -> Result<Response, ApiError> {
90 let user = state
91 .user_repo
92 .get_by_did(&auth.did)
93 .await
94 .log_db_err("getting user")?
95 .ok_or(ApiError::AccountNotFound)?;
96
97 let name = input.name.trim();
98 if name.is_empty() {
99 return Err(ApiError::InvalidRequest("name is required".into()));
100 }
101
102 if state
103 .session_repo
104 .get_app_password_by_name(user.id, name)
105 .await
106 .log_db_err("checking app password")?
107 .is_some()
108 {
109 return Err(ApiError::DuplicateAppPassword);
110 }
111
112 let (final_scopes, controller_did) = if let Some(ref controller) = auth.controller_did {
113 let grant = state
114 .delegation_repo
115 .get_delegation(&auth.did, controller)
116 .await
117 .ok()
118 .flatten();
119 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
120
121 let requested = input.scopes.as_deref().unwrap_or("atproto");
122 let intersected = intersect_scopes(requested, granted_scopes.as_str());
123
124 if intersected.is_empty() && !granted_scopes.is_empty() {
125 return Err(ApiError::InsufficientScope(None));
126 }
127
128 let scope_result = if intersected.is_empty() {
129 None
130 } else {
131 Some(intersected)
132 };
133 (scope_result, Some(controller.clone()))
134 } else {
135 (input.scopes.clone(), None)
136 };
137
138 let password = generate_app_password();
139
140 let password_clone = password.clone();
141 let password_hash =
142 tokio::task::spawn_blocking(move || bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST))
143 .await
144 .map_err(|e| {
145 error!("Failed to spawn blocking task: {:?}", e);
146 ApiError::InternalError(None)
147 })?
148 .map_err(|e| {
149 error!("Failed to hash password: {:?}", e);
150 ApiError::InternalError(None)
151 })?;
152
153 let privilege = tranquil_db_traits::AppPasswordPrivilege::from_privileged_flag(
154 input.privileged.unwrap_or(false),
155 );
156 let created_at = chrono::Utc::now();
157
158 let create_data = AppPasswordCreate {
159 user_id: user.id,
160 name: name.to_string(),
161 password_hash,
162 privilege,
163 scopes: final_scopes.clone(),
164 created_by_controller_did: controller_did.clone(),
165 };
166
167 state
168 .session_repo
169 .create_app_password(&create_data)
170 .await
171 .log_db_err("creating app password")?;
172
173 if let Some(ref controller) = controller_did {
174 let _ = state
175 .delegation_repo
176 .log_delegation_action(
177 &auth.did,
178 controller,
179 Some(controller),
180 DelegationActionType::AccountAction,
181 Some(json!({
182 "action": "create_app_password",
183 "name": name,
184 "scopes": final_scopes
185 })),
186 None,
187 None,
188 )
189 .await;
190 }
191 Ok(Json(CreateAppPasswordOutput {
192 name: name.to_string(),
193 password,
194 created_at: created_at.to_rfc3339(),
195 privileged: privilege.is_privileged(),
196 scopes: final_scopes,
197 })
198 .into_response())
199}
200
201#[derive(Deserialize)]
202pub struct RevokeAppPasswordInput {
203 pub name: String,
204}
205
206pub async fn revoke_app_password(
207 State(state): State<AppState>,
208 auth: Auth<Permissive>,
209 Json(input): Json<RevokeAppPasswordInput>,
210) -> Result<Response, ApiError> {
211 let user = state
212 .user_repo
213 .get_by_did(&auth.did)
214 .await
215 .log_db_err("getting user")?
216 .ok_or(ApiError::AccountNotFound)?;
217
218 let name = input.name.trim();
219 if name.is_empty() {
220 return Err(ApiError::InvalidRequest("name is required".into()));
221 }
222
223 let sessions_to_invalidate = state
224 .session_repo
225 .get_session_jtis_by_app_password(&auth.did, name)
226 .await
227 .unwrap_or_default();
228
229 state
230 .session_repo
231 .delete_sessions_by_app_password(&auth.did, name)
232 .await
233 .log_db_err("revoking sessions for app password")?;
234
235 futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
236 let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, jti);
237 let cache = state.cache.clone();
238 async move {
239 let _ = cache.delete(&cache_key).await;
240 }
241 }))
242 .await;
243
244 state
245 .session_repo
246 .delete_app_password(user.id, name)
247 .await
248 .log_db_err("revoking app password")?;
249
250 Ok(EmptyResponse::ok().into_response())
251}