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