Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

App password conf. vs ref

+237 -30
+10 -4
.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json .sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 3 + "query": "SELECT name, password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 4 4 "describe": { 5 5 "columns": [ 6 6 { 7 7 "ordinal": 0, 8 - "name": "password_hash", 8 + "name": "name", 9 9 "type_info": "Text" 10 10 }, 11 11 { 12 12 "ordinal": 1, 13 - "name": "scopes", 13 + "name": "password_hash", 14 14 "type_info": "Text" 15 15 }, 16 16 { 17 17 "ordinal": 2, 18 + "name": "scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 18 23 "name": "created_by_controller_did", 19 24 "type_info": "Text" 20 25 } ··· 26 31 }, 27 32 "nullable": [ 28 33 false, 34 + false, 29 35 true, 30 36 true 31 37 ] 32 38 }, 33 - "hash": "1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b" 39 + "hash": "8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215" 34 40 }
+23
.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "access_jti", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7" 23 + }
+3 -2
.sqlx/query-bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38.json .sqlx/query-8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 3 + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did, app_password_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 13 13 "Bool", 14 14 "Bool", 15 15 "Text", 16 + "Text", 16 17 "Text" 17 18 ] 18 19 }, 19 20 "nullable": [] 20 21 }, 21 - "hash": "bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38" 22 + "hash": "8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5" 22 23 }
+15
.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca" 15 + }
+2
migrations/20251241_session_app_password_name.sql
··· 1 + ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT; 2 + CREATE INDEX idx_session_tokens_app_password ON session_tokens(did, app_password_name) WHERE app_password_name IS NOT NULL;
+27 -11
src/api/server/app_password.rs
··· 232 232 if name.is_empty() { 233 233 return ApiError::InvalidRequest("name is required".into()).into_response(); 234 234 } 235 - match sqlx::query!( 235 + let sessions_to_invalidate = sqlx::query_scalar!( 236 + "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", 237 + auth_user.did, 238 + name 239 + ) 240 + .fetch_all(&state.db) 241 + .await 242 + .unwrap_or_default(); 243 + if let Err(e) = sqlx::query!( 244 + "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", 245 + auth_user.did, 246 + name 247 + ) 248 + .execute(&state.db) 249 + .await 250 + { 251 + error!("DB error revoking sessions for app password: {:?}", e); 252 + return ApiError::InternalError.into_response(); 253 + } 254 + for jti in &sessions_to_invalidate { 255 + let cache_key = format!("auth:session:{}:{}", auth_user.did, jti); 256 + let _ = state.cache.delete(&cache_key).await; 257 + } 258 + if let Err(e) = sqlx::query!( 236 259 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", 237 260 user_id, 238 261 name ··· 240 263 .execute(&state.db) 241 264 .await 242 265 { 243 - Ok(r) => { 244 - if r.rows_affected() == 0 { 245 - return ApiError::AppPasswordNotFound.into_response(); 246 - } 247 - Json(json!({})).into_response() 248 - } 249 - Err(e) => { 250 - error!("DB error revoking app password: {:?}", e); 251 - ApiError::InternalError.into_response() 252 - } 266 + error!("DB error revoking app password: {:?}", e); 267 + return ApiError::InternalError.into_response(); 253 268 } 269 + Json(json!({})).into_response() 254 270 }
+13 -7
src/api/server/meta.rs
··· 35 35 let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); 36 36 let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); 37 37 let contact_email = std::env::var("CONTACT_EMAIL").ok(); 38 + let mut links = serde_json::Map::new(); 39 + if let Some(pp) = privacy_policy { 40 + links.insert("privacyPolicy".to_string(), json!(pp)); 41 + } 42 + if let Some(tos) = terms_of_service { 43 + links.insert("termsOfService".to_string(), json!(tos)); 44 + } 45 + let mut contact = serde_json::Map::new(); 46 + if let Some(email) = contact_email { 47 + contact.insert("email".to_string(), json!(email)); 48 + } 38 49 Json(json!({ 39 50 "availableUserDomains": domains, 40 51 "inviteCodeRequired": invite_code_required, 41 52 "did": format!("did:web:{}", pds_hostname), 42 - "links": { 43 - "privacyPolicy": privacy_policy, 44 - "termsOfService": terms_of_service 45 - }, 46 - "contact": { 47 - "email": contact_email 48 - }, 53 + "links": links, 54 + "contact": contact, 49 55 "version": env!("CARGO_PKG_VERSION"), 50 56 "availableCommsChannels": get_available_comms_channels() 51 57 }))
+8 -6
src/api/server/session.rs
··· 138 138 return ApiError::InternalError.into_response(); 139 139 } 140 140 }; 141 - let (password_valid, app_password_scopes, app_password_controller) = if row 141 + let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row 142 142 .password_hash 143 143 .as_ref() 144 144 .map(|h| verify(&input.password, h).unwrap_or(false)) 145 145 .unwrap_or(false) 146 146 { 147 - (true, None, None) 147 + (true, None, None, None) 148 148 } else { 149 149 let app_passwords = sqlx::query!( 150 - "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 150 + "SELECT name, password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 151 151 row.id 152 152 ) 153 153 .fetch_all(&state.db) ··· 159 159 match matched { 160 160 Some(app) => ( 161 161 true, 162 + Some(app.name.clone()), 162 163 app.scopes.clone(), 163 164 app.created_by_controller_did.clone(), 164 165 ), 165 - None => (false, None, None), 166 + None => (false, None, None, None), 166 167 } 167 168 }; 168 169 if !password_valid { ··· 236 237 let did_resolver = state.did_resolver.clone(); 237 238 let (insert_result, did_doc) = tokio::join!( 238 239 sqlx::query!( 239 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 240 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did, app_password_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", 240 241 row.did, 241 242 access_meta.jti, 242 243 refresh_meta.jti, ··· 245 246 is_legacy_login, 246 247 false, 247 248 app_password_scopes, 248 - app_password_controller 249 + app_password_controller, 250 + app_password_name 249 251 ) 250 252 .execute(&state.db), 251 253 did_resolver.resolve_did_document(&did_for_doc)
+136
tests/lifecycle_session.rs
··· 286 286 } 287 287 288 288 #[tokio::test] 289 + async fn test_app_password_duplicate_name() { 290 + let client = client(); 291 + let base = base_url().await; 292 + let (jwt, _did) = create_account_and_login(&client).await; 293 + let create_res = client 294 + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 295 + .bearer_auth(&jwt) 296 + .json(&json!({ "name": "My App" })) 297 + .send() 298 + .await 299 + .expect("Failed to create app password"); 300 + assert_eq!(create_res.status(), StatusCode::OK); 301 + let duplicate_res = client 302 + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 303 + .bearer_auth(&jwt) 304 + .json(&json!({ "name": "My App" })) 305 + .send() 306 + .await 307 + .expect("Failed to attempt duplicate"); 308 + assert_eq!( 309 + duplicate_res.status(), 310 + StatusCode::BAD_REQUEST, 311 + "Duplicate app password name should fail" 312 + ); 313 + let body: Value = duplicate_res.json().await.unwrap(); 314 + assert_eq!(body["error"], "DuplicateAppPassword"); 315 + } 316 + 317 + #[tokio::test] 318 + async fn test_app_password_revoke_nonexistent() { 319 + let client = client(); 320 + let base = base_url().await; 321 + let (jwt, _did) = create_account_and_login(&client).await; 322 + let revoke_res = client 323 + .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 324 + .bearer_auth(&jwt) 325 + .json(&json!({ "name": "Does Not Exist" })) 326 + .send() 327 + .await 328 + .expect("Failed to revoke"); 329 + assert_eq!( 330 + revoke_res.status(), 331 + StatusCode::OK, 332 + "Revoking non-existent app password should succeed silently" 333 + ); 334 + } 335 + 336 + #[tokio::test] 337 + async fn test_app_password_revoke_invalidates_sessions() { 338 + let client = client(); 339 + let base = base_url().await; 340 + let ts = Utc::now().timestamp_millis(); 341 + let handle = format!("apppass-inv-{}.test", ts); 342 + let email = format!("apppass-inv-{}@test.com", ts); 343 + let password = "ApppassInv123!"; 344 + let create_res = client 345 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 346 + .json(&json!({ 347 + "handle": handle, 348 + "email": email, 349 + "password": password 350 + })) 351 + .send() 352 + .await 353 + .expect("Failed to create account"); 354 + assert_eq!(create_res.status(), StatusCode::OK); 355 + let account: Value = create_res.json().await.unwrap(); 356 + let did = account["did"].as_str().unwrap(); 357 + let main_jwt = verify_new_account(&client, did).await; 358 + let create_app_res = client 359 + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) 360 + .bearer_auth(&main_jwt) 361 + .json(&json!({ "name": "Session Test App" })) 362 + .send() 363 + .await 364 + .expect("Failed to create app password"); 365 + assert_eq!(create_app_res.status(), StatusCode::OK); 366 + let app_pass: Value = create_app_res.json().await.unwrap(); 367 + let app_password = app_pass["password"].as_str().unwrap(); 368 + let app_session_res = client 369 + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 370 + .json(&json!({ 371 + "identifier": handle, 372 + "password": app_password 373 + })) 374 + .send() 375 + .await 376 + .expect("Failed to login with app password"); 377 + assert_eq!(app_session_res.status(), StatusCode::OK); 378 + let app_session: Value = app_session_res.json().await.unwrap(); 379 + let app_jwt = app_session["accessJwt"].as_str().unwrap(); 380 + let get_session_res = client 381 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 382 + .bearer_auth(app_jwt) 383 + .send() 384 + .await 385 + .expect("Failed to get session"); 386 + assert_eq!( 387 + get_session_res.status(), 388 + StatusCode::OK, 389 + "App password session should be valid before revocation" 390 + ); 391 + let revoke_res = client 392 + .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) 393 + .bearer_auth(&main_jwt) 394 + .json(&json!({ "name": "Session Test App" })) 395 + .send() 396 + .await 397 + .expect("Failed to revoke app password"); 398 + assert_eq!(revoke_res.status(), StatusCode::OK); 399 + let get_session_after = client 400 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 401 + .bearer_auth(app_jwt) 402 + .send() 403 + .await 404 + .expect("Failed to check session after revoke"); 405 + assert!( 406 + get_session_after.status() == StatusCode::UNAUTHORIZED 407 + || get_session_after.status() == StatusCode::BAD_REQUEST, 408 + "Session created with revoked app password should be invalid, got {}", 409 + get_session_after.status() 410 + ); 411 + let main_session_res = client 412 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 413 + .bearer_auth(&main_jwt) 414 + .send() 415 + .await 416 + .expect("Failed to check main session"); 417 + assert_eq!( 418 + main_session_res.status(), 419 + StatusCode::OK, 420 + "Main session should still be valid after revoking app password" 421 + ); 422 + } 423 + 424 + #[tokio::test] 289 425 async fn test_account_deactivation_lifecycle() { 290 426 let client = client(); 291 427 let ts = Utc::now().timestamp_millis();