mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-08 21:30:08 +00:00
App password conf. vs ref
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
23
.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json
generated
Normal file
23
.sqlx/query-9fa0a8c713e0d34706b73280df5fe3d1c42a1f03f6283db8104136667f64b1e7.json
generated
Normal 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"
|
||||
}
|
||||
15
.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json
generated
Normal file
15
.sqlx/query-fdcbf30dd11f7705630fc687af1acb0489f82359b57ca360fc4fda11e2e611ca.json
generated
Normal 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"
|
||||
}
|
||||
2
migrations/20251241_session_app_password_name.sql
Normal file
2
migrations/20251241_session_app_password_name.sql
Normal 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;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user