App password conf. vs ref

This commit is contained in:
lewis
2025-12-29 19:36:01 +02:00
parent 5aceed2ab3
commit 8cfc13fccc
9 changed files with 237 additions and 30 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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()
}))

View File

@@ -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)

View File

@@ -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();