+10
-4
.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json
.sqlx/query-8b2f76eecb2f9383471a2d68f13696d40778b931cefe7553f026d512dddf3215.json
+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
+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
+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
+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
+2
migrations/20251241_session_app_password_name.sql
+27
-11
src/api/server/app_password.rs
+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
+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
+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
+136
tests/lifecycle_session.rs
···
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();