diff --git a/.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json b/.sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json similarity index 60% rename from .sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json rename to .sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json index cd6ad93..d472d83 100644 --- a/.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json +++ b/.sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json @@ -1,20 +1,25 @@ { "db_name": "PostgreSQL", - "query": "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", + "query": "SELECT name, password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", "describe": { "columns": [ { "ordinal": 0, - "name": "password_hash", + "name": "name", "type_info": "Text" }, { "ordinal": 1, - "name": "scopes", + "name": "password_hash", "type_info": "Text" }, { "ordinal": 2, + "name": "scopes", + "type_info": "Text" + }, + { + "ordinal": 3, "name": "created_by_controller_did", "type_info": "Text" } @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, true, true ] }, - "hash": "1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b" + "hash": "8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215" } diff --git a/.sqlx/query-bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38.json b/.sqlx/query-8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5.json similarity index 68% rename from .sqlx/query-bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38.json rename to .sqlx/query-8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5.json index 7fc22b6..dfd38d2 100644 --- a/.sqlx/query-bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38.json +++ b/.sqlx/query-8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "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)", + "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)", "describe": { "columns": [], "parameters": { @@ -13,10 +13,11 @@ "Bool", "Bool", "Text", + "Text", "Text" ] }, "nullable": [] }, - "hash": "bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38" + "hash": "8c8d674237c8785cae1698e7a722cc125975945b25256b02ec4eb5cca225e0e5" } diff --git a/.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json b/.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json new file mode 100644 index 0000000..5c535b1 --- /dev/null +++ b/.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "access_jti", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7" +} diff --git a/.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json b/.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json new file mode 100644 index 0000000..0d480fd --- /dev/null +++ b/.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca" +} diff --git a/migrations/20251241_session_app_password_name.sql b/migrations/20251241_session_app_password_name.sql new file mode 100644 index 0000000..9617081 --- /dev/null +++ b/migrations/20251241_session_app_password_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE session_tokens ADD COLUMN app_password_name TEXT; +CREATE INDEX idx_session_tokens_app_password ON session_tokens(did, app_password_name) WHERE app_password_name IS NOT NULL; diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 28fdc1f..0772d9c 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -232,7 +232,30 @@ pub async fn revoke_app_password( if name.is_empty() { return ApiError::InvalidRequest("name is required".into()).into_response(); } - match sqlx::query!( + let sessions_to_invalidate = sqlx::query_scalar!( + "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", + auth_user.did, + name + ) + .fetch_all(&state.db) + .await + .unwrap_or_default(); + if let Err(e) = sqlx::query!( + "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", + auth_user.did, + name + ) + .execute(&state.db) + .await + { + error!("DB error revoking sessions for app password: {:?}", e); + return ApiError::InternalError.into_response(); + } + for jti in &sessions_to_invalidate { + let cache_key = format!("auth:session:{}:{}", auth_user.did, jti); + let _ = state.cache.delete(&cache_key).await; + } + if let Err(e) = sqlx::query!( "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", user_id, name @@ -240,15 +263,8 @@ pub async fn revoke_app_password( .execute(&state.db) .await { - Ok(r) => { - if r.rows_affected() == 0 { - return ApiError::AppPasswordNotFound.into_response(); - } - Json(json!({})).into_response() - } - Err(e) => { - error!("DB error revoking app password: {:?}", e); - ApiError::InternalError.into_response() - } + error!("DB error revoking app password: {:?}", e); + return ApiError::InternalError.into_response(); } + Json(json!({})).into_response() } diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs index 2305f29..063c956 100644 --- a/src/api/server/meta.rs +++ b/src/api/server/meta.rs @@ -35,17 +35,23 @@ pub async fn describe_server() -> impl IntoResponse { let privacy_policy = std::env::var("PRIVACY_POLICY_URL").ok(); let terms_of_service = std::env::var("TERMS_OF_SERVICE_URL").ok(); let contact_email = std::env::var("CONTACT_EMAIL").ok(); + let mut links = serde_json::Map::new(); + if let Some(pp) = privacy_policy { + links.insert("privacyPolicy".to_string(), json!(pp)); + } + if let Some(tos) = terms_of_service { + links.insert("termsOfService".to_string(), json!(tos)); + } + let mut contact = serde_json::Map::new(); + if let Some(email) = contact_email { + contact.insert("email".to_string(), json!(email)); + } Json(json!({ "availableUserDomains": domains, "inviteCodeRequired": invite_code_required, "did": format!("did:web:{}", pds_hostname), - "links": { - "privacyPolicy": privacy_policy, - "termsOfService": terms_of_service - }, - "contact": { - "email": contact_email - }, + "links": links, + "contact": contact, "version": env!("CARGO_PKG_VERSION"), "availableCommsChannels": get_available_comms_channels() })) diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 8cfb784..22354c2 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -138,16 +138,16 @@ pub async fn create_session( return ApiError::InternalError.into_response(); } }; - let (password_valid, app_password_scopes, app_password_controller) = if row + let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row .password_hash .as_ref() .map(|h| verify(&input.password, h).unwrap_or(false)) .unwrap_or(false) { - (true, None, None) + (true, None, None, None) } else { let app_passwords = sqlx::query!( - "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", + "SELECT name, password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", row.id ) .fetch_all(&state.db) @@ -159,10 +159,11 @@ pub async fn create_session( match matched { Some(app) => ( true, + Some(app.name.clone()), app.scopes.clone(), app.created_by_controller_did.clone(), ), - None => (false, None, None), + None => (false, None, None, None), } }; if !password_valid { @@ -236,7 +237,7 @@ pub async fn create_session( let did_resolver = state.did_resolver.clone(); let (insert_result, did_doc) = tokio::join!( sqlx::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)", + "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)", row.did, access_meta.jti, refresh_meta.jti, @@ -245,7 +246,8 @@ pub async fn create_session( is_legacy_login, false, app_password_scopes, - app_password_controller + app_password_controller, + app_password_name ) .execute(&state.db), did_resolver.resolve_did_document(&did_for_doc) diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index 399d1f0..85a5b10 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -285,6 +285,142 @@ async fn test_app_password_lifecycle() { assert_eq!(passwords_after.len(), 0, "No app passwords should remain"); } +#[tokio::test] +async fn test_app_password_duplicate_name() { + let client = client(); + let base = base_url().await; + let (jwt, _did) = create_account_and_login(&client).await; + let create_res = client + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) + .bearer_auth(&jwt) + .json(&json!({ "name": "My App" })) + .send() + .await + .expect("Failed to create app password"); + assert_eq!(create_res.status(), StatusCode::OK); + let duplicate_res = client + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) + .bearer_auth(&jwt) + .json(&json!({ "name": "My App" })) + .send() + .await + .expect("Failed to attempt duplicate"); + assert_eq!( + duplicate_res.status(), + StatusCode::BAD_REQUEST, + "Duplicate app password name should fail" + ); + let body: Value = duplicate_res.json().await.unwrap(); + assert_eq!(body["error"], "DuplicateAppPassword"); +} + +#[tokio::test] +async fn test_app_password_revoke_nonexistent() { + let client = client(); + let base = base_url().await; + let (jwt, _did) = create_account_and_login(&client).await; + let revoke_res = client + .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) + .bearer_auth(&jwt) + .json(&json!({ "name": "Does Not Exist" })) + .send() + .await + .expect("Failed to revoke"); + assert_eq!( + revoke_res.status(), + StatusCode::OK, + "Revoking non-existent app password should succeed silently" + ); +} + +#[tokio::test] +async fn test_app_password_revoke_invalidates_sessions() { + let client = client(); + let base = base_url().await; + let ts = Utc::now().timestamp_millis(); + let handle = format!("apppass-inv-{}.test", ts); + let email = format!("apppass-inv-{}@test.com", ts); + let password = "ApppassInv123!"; + let create_res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) + .json(&json!({ + "handle": handle, + "email": email, + "password": password + })) + .send() + .await + .expect("Failed to create account"); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap(); + let main_jwt = verify_new_account(&client, did).await; + let create_app_res = client + .post(format!("{}/xrpc/com.atproto.server.createAppPassword", base)) + .bearer_auth(&main_jwt) + .json(&json!({ "name": "Session Test App" })) + .send() + .await + .expect("Failed to create app password"); + assert_eq!(create_app_res.status(), StatusCode::OK); + let app_pass: Value = create_app_res.json().await.unwrap(); + let app_password = app_pass["password"].as_str().unwrap(); + let app_session_res = client + .post(format!("{}/xrpc/com.atproto.server.createSession", base)) + .json(&json!({ + "identifier": handle, + "password": app_password + })) + .send() + .await + .expect("Failed to login with app password"); + assert_eq!(app_session_res.status(), StatusCode::OK); + let app_session: Value = app_session_res.json().await.unwrap(); + let app_jwt = app_session["accessJwt"].as_str().unwrap(); + let get_session_res = client + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) + .bearer_auth(app_jwt) + .send() + .await + .expect("Failed to get session"); + assert_eq!( + get_session_res.status(), + StatusCode::OK, + "App password session should be valid before revocation" + ); + let revoke_res = client + .post(format!("{}/xrpc/com.atproto.server.revokeAppPassword", base)) + .bearer_auth(&main_jwt) + .json(&json!({ "name": "Session Test App" })) + .send() + .await + .expect("Failed to revoke app password"); + assert_eq!(revoke_res.status(), StatusCode::OK); + let get_session_after = client + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) + .bearer_auth(app_jwt) + .send() + .await + .expect("Failed to check session after revoke"); + assert!( + get_session_after.status() == StatusCode::UNAUTHORIZED + || get_session_after.status() == StatusCode::BAD_REQUEST, + "Session created with revoked app password should be invalid, got {}", + get_session_after.status() + ); + let main_session_res = client + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) + .bearer_auth(&main_jwt) + .send() + .await + .expect("Failed to check main session"); + assert_eq!( + main_session_res.status(), + StatusCode::OK, + "Main session should still be valid after revoking app password" + ); +} + #[tokio::test] async fn test_account_deactivation_lifecycle() { let client = client();