this repo has no description

App password conf. vs ref

lewis 266d3721 a0ea2f24

+10 -4
.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json .sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json
··· 1 { 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", 4 "describe": { 5 "columns": [ 6 { 7 "ordinal": 0, 8 - "name": "password_hash", 9 "type_info": "Text" 10 }, 11 { 12 "ordinal": 1, 13 - "name": "scopes", 14 "type_info": "Text" 15 }, 16 { 17 "ordinal": 2, 18 "name": "created_by_controller_did", 19 "type_info": "Text" 20 } ··· 26 }, 27 "nullable": [ 28 false, 29 true, 30 true 31 ] 32 }, 33 - "hash": "1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b" 34 }
··· 1 { 2 "db_name": "PostgreSQL", 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 "describe": { 5 "columns": [ 6 { 7 "ordinal": 0, 8 + "name": "name", 9 "type_info": "Text" 10 }, 11 { 12 "ordinal": 1, 13 + "name": "password_hash", 14 "type_info": "Text" 15 }, 16 { 17 "ordinal": 2, 18 + "name": "scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 "name": "created_by_controller_did", 24 "type_info": "Text" 25 } ··· 31 }, 32 "nullable": [ 33 false, 34 + false, 35 true, 36 true 37 ] 38 }, 39 + "hash": "8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215" 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 { 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)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 13 "Bool", 14 "Bool", 15 "Text", 16 "Text" 17 ] 18 }, 19 "nullable": [] 20 }, 21 - "hash": "bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38" 22 }
··· 1 { 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, app_password_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", 4 "describe": { 5 "columns": [], 6 "parameters": { ··· 13 "Bool", 14 "Bool", 15 "Text", 16 + "Text", 17 "Text" 18 ] 19 }, 20 "nullable": [] 21 }, 22 + "hash": "8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5" 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 if name.is_empty() { 233 return ApiError::InvalidRequest("name is required".into()).into_response(); 234 } 235 - match sqlx::query!( 236 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", 237 user_id, 238 name ··· 240 .execute(&state.db) 241 .await 242 { 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 - } 253 } 254 }
··· 232 if name.is_empty() { 233 return ApiError::InvalidRequest("name is required".into()).into_response(); 234 } 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!( 259 "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", 260 user_id, 261 name ··· 263 .execute(&state.db) 264 .await 265 { 266 + error!("DB error revoking app password: {:?}", e); 267 + return ApiError::InternalError.into_response(); 268 } 269 + Json(json!({})).into_response() 270 }
+13 -7
src/api/server/meta.rs
··· 35 let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); 36 let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); 37 let contact_email = std::env::var("CONTACT_EMAIL").ok(); 38 Json(json!({ 39 "availableUserDomains": domains, 40 "inviteCodeRequired": invite_code_required, 41 "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 - }, 49 "version": env!("CARGO_PKG_VERSION"), 50 "availableCommsChannels": get_available_comms_channels() 51 }))
··· 35 let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); 36 let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); 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 + } 49 Json(json!({ 50 "availableUserDomains": domains, 51 "inviteCodeRequired": invite_code_required, 52 "did": format!("did:web:{}", pds_hostname), 53 + "links": links, 54 + "contact": contact, 55 "version": env!("CARGO_PKG_VERSION"), 56 "availableCommsChannels": get_available_comms_channels() 57 }))
+8 -6
src/api/server/session.rs
··· 138 return ApiError::InternalError.into_response(); 139 } 140 }; 141 - let (password_valid, app_password_scopes, app_password_controller) = if row 142 .password_hash 143 .as_ref() 144 .map(|h| verify(&input.password, h).unwrap_or(false)) 145 .unwrap_or(false) 146 { 147 - (true, None, None) 148 } else { 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", 151 row.id 152 ) 153 .fetch_all(&state.db) ··· 159 match matched { 160 Some(app) => ( 161 true, 162 app.scopes.clone(), 163 app.created_by_controller_did.clone(), 164 ), 165 - None => (false, None, None), 166 } 167 }; 168 if !password_valid { ··· 236 let did_resolver = state.did_resolver.clone(); 237 let (insert_result, did_doc) = tokio::join!( 238 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 row.did, 241 access_meta.jti, 242 refresh_meta.jti, ··· 245 is_legacy_login, 246 false, 247 app_password_scopes, 248 - app_password_controller 249 ) 250 .execute(&state.db), 251 did_resolver.resolve_did_document(&did_for_doc)
··· 138 return ApiError::InternalError.into_response(); 139 } 140 }; 141 + let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row 142 .password_hash 143 .as_ref() 144 .map(|h| verify(&input.password, h).unwrap_or(false)) 145 .unwrap_or(false) 146 { 147 + (true, None, None, None) 148 } else { 149 let app_passwords = sqlx::query!( 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 row.id 152 ) 153 .fetch_all(&state.db) ··· 159 match matched { 160 Some(app) => ( 161 true, 162 + Some(app.name.clone()), 163 app.scopes.clone(), 164 app.created_by_controller_did.clone(), 165 ), 166 + None => (false, None, None, None), 167 } 168 }; 169 if !password_valid { ··· 237 let did_resolver = state.did_resolver.clone(); 238 let (insert_result, did_doc) = tokio::join!( 239 sqlx::query!( 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)", 241 row.did, 242 access_meta.jti, 243 refresh_meta.jti, ··· 246 is_legacy_login, 247 false, 248 app_password_scopes, 249 + app_password_controller, 250 + app_password_name 251 ) 252 .execute(&state.db), 253 did_resolver.resolve_did_document(&did_for_doc)
+136
tests/lifecycle_session.rs
··· 286 } 287 288 #[tokio::test] 289 async fn test_account_deactivation_lifecycle() { 290 let client = client(); 291 let ts = Utc::now().timestamp_millis();
··· 286 } 287 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] 425 async fn test_account_deactivation_lifecycle() { 426 let client = client(); 427 let ts = Utc::now().timestamp_millis();