this repo has no description
1use crate::api::EmptyResponse;
2use crate::api::error::ApiError;
3use crate::auth::BearerAuth;
4use crate::delegation::{self, DelegationActionType};
5use crate::state::{AppState, RateLimitKind};
6use crate::util::get_user_id_by_did;
7use axum::{
8 Json,
9 extract::State,
10 http::HeaderMap,
11 response::{IntoResponse, Response},
12};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15use tracing::{error, warn};
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 BearerAuth(auth_user): BearerAuth,
37) -> Response {
38 let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
39 Ok(id) => id,
40 Err(e) => return ApiError::from(e).into_response(),
41 };
42 match sqlx::query!(
43 "SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
44 user_id
45 )
46 .fetch_all(&state.db)
47 .await
48 {
49 Ok(rows) => {
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.privileged,
56 scopes: row.scopes.clone(),
57 created_by_controller: row.created_by_controller_did.clone(),
58 })
59 .collect();
60 Json(ListAppPasswordsOutput { passwords }).into_response()
61 }
62 Err(e) => {
63 error!("DB error listing app passwords: {:?}", e);
64 ApiError::InternalError(None).into_response()
65 }
66 }
67}
68
69#[derive(Deserialize)]
70pub struct CreateAppPasswordInput {
71 pub name: String,
72 pub privileged: Option<bool>,
73 pub scopes: Option<String>,
74}
75
76#[derive(Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct CreateAppPasswordOutput {
79 pub name: String,
80 pub password: String,
81 pub created_at: String,
82 pub privileged: bool,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub scopes: Option<String>,
85}
86
87pub async fn create_app_password(
88 State(state): State<AppState>,
89 headers: HeaderMap,
90 BearerAuth(auth_user): BearerAuth,
91 Json(input): Json<CreateAppPasswordInput>,
92) -> Response {
93 let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
94 if !state
95 .check_rate_limit(RateLimitKind::AppPassword, &client_ip)
96 .await
97 {
98 warn!(ip = %client_ip, "App password creation rate limit exceeded");
99 return ApiError::RateLimitExceeded(None).into_response();
100 }
101 let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
102 Ok(id) => id,
103 Err(e) => return ApiError::from(e).into_response(),
104 };
105 let name = input.name.trim();
106 if name.is_empty() {
107 return ApiError::InvalidRequest("name is required".into()).into_response();
108 }
109 let existing = sqlx::query!(
110 "SELECT id FROM app_passwords WHERE user_id = $1 AND name = $2",
111 user_id,
112 name
113 )
114 .fetch_optional(&state.db)
115 .await;
116 if let Ok(Some(_)) = existing {
117 return ApiError::DuplicateAppPassword.into_response();
118 }
119
120 let (final_scopes, controller_did) = if let Some(ref controller) = auth_user.controller_did {
121 let grant = delegation::get_delegation(&state.db, &auth_user.did, controller)
122 .await
123 .ok()
124 .flatten();
125 let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
126
127 let requested = input.scopes.as_deref().unwrap_or("atproto");
128 let intersected = delegation::intersect_scopes(requested, &granted_scopes);
129
130 if intersected.is_empty() && !granted_scopes.is_empty() {
131 return ApiError::InsufficientScope(None).into_response();
132 }
133
134 let scope_result = if intersected.is_empty() {
135 None
136 } else {
137 Some(intersected)
138 };
139 (scope_result, Some(controller.clone()))
140 } else {
141 (input.scopes.clone(), None)
142 };
143
144 let password: String = (0..4)
145 .map(|_| {
146 use rand::Rng;
147 let mut rng = rand::thread_rng();
148 let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect();
149 (0..4)
150 .map(|_| chars[rng.gen_range(0..chars.len())])
151 .collect::<String>()
152 })
153 .collect::<Vec<String>>()
154 .join("-");
155 let password_clone = password.clone();
156 let password_hash = match tokio::task::spawn_blocking(move || {
157 bcrypt::hash(&password_clone, bcrypt::DEFAULT_COST)
158 })
159 .await
160 {
161 Ok(Ok(h)) => h,
162 Ok(Err(e)) => {
163 error!("Failed to hash password: {:?}", e);
164 return ApiError::InternalError(None).into_response();
165 }
166 Err(e) => {
167 error!("Failed to spawn blocking task: {:?}", e);
168 return ApiError::InternalError(None).into_response();
169 }
170 };
171 let privileged = input.privileged.unwrap_or(false);
172 let created_at = chrono::Utc::now();
173 match sqlx::query!(
174 "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)",
175 user_id,
176 name,
177 password_hash,
178 created_at,
179 privileged,
180 final_scopes,
181 controller_did.as_deref()
182 )
183 .execute(&state.db)
184 .await
185 {
186 Ok(_) => {
187 if let Some(ref controller) = controller_did {
188 let _ = delegation::log_delegation_action(
189 &state.db,
190 &auth_user.did,
191 controller,
192 Some(controller),
193 DelegationActionType::AccountAction,
194 Some(json!({
195 "action": "create_app_password",
196 "name": name,
197 "scopes": final_scopes
198 })),
199 None,
200 None,
201 )
202 .await;
203 }
204 Json(CreateAppPasswordOutput {
205 name: name.to_string(),
206 password,
207 created_at: created_at.to_rfc3339(),
208 privileged,
209 scopes: final_scopes,
210 })
211 .into_response()
212 }
213 Err(e) => {
214 error!("DB error creating app password: {:?}", e);
215 ApiError::InternalError(None).into_response()
216 }
217 }
218}
219
220#[derive(Deserialize)]
221pub struct RevokeAppPasswordInput {
222 pub name: String,
223}
224
225pub async fn revoke_app_password(
226 State(state): State<AppState>,
227 BearerAuth(auth_user): BearerAuth,
228 Json(input): Json<RevokeAppPasswordInput>,
229) -> Response {
230 let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
231 Ok(id) => id,
232 Err(e) => return ApiError::from(e).into_response(),
233 };
234 let name = input.name.trim();
235 if name.is_empty() {
236 return ApiError::InvalidRequest("name is required".into()).into_response();
237 }
238 let sessions_to_invalidate = sqlx::query_scalar!(
239 "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2",
240 &auth_user.did,
241 name
242 )
243 .fetch_all(&state.db)
244 .await
245 .unwrap_or_default();
246 if let Err(e) = sqlx::query!(
247 "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2",
248 &auth_user.did,
249 name
250 )
251 .execute(&state.db)
252 .await
253 {
254 error!("DB error revoking sessions for app password: {:?}", e);
255 return ApiError::InternalError(None).into_response();
256 }
257 futures::future::join_all(sessions_to_invalidate.iter().map(|jti| {
258 let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti);
259 let cache = state.cache.clone();
260 async move {
261 let _ = cache.delete(&cache_key).await;
262 }
263 }))
264 .await;
265 if let Err(e) = sqlx::query!(
266 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2",
267 user_id,
268 name
269 )
270 .execute(&state.db)
271 .await
272 {
273 error!("DB error revoking app password: {:?}", e);
274 return ApiError::InternalError(None).into_response();
275 }
276 EmptyResponse::ok().into_response()
277}