diff --git a/.sqlx/query-642b7199f2cbde74af72fc5b5b80f9e2b3efe901a3fdfc732f0d36d00db6326f.json b/.sqlx/query-642b7199f2cbde74af72fc5b5b80f9e2b3efe901a3fdfc732f0d36d00db6326f.json new file mode 100644 index 0000000..0773762 --- /dev/null +++ b/.sqlx/query-642b7199f2cbde74af72fc5b5b80f9e2b3efe901a3fdfc732f0d36d00db6326f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM invite_codes WHERE created_by_user = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "642b7199f2cbde74af72fc5b5b80f9e2b3efe901a3fdfc732f0d36d00db6326f" +} diff --git a/.sqlx/query-6c71c4ac31f897e9d33a3637d89377c5977f76a117b042e1800b890b84a655ea.json b/.sqlx/query-6c71c4ac31f897e9d33a3637d89377c5977f76a117b042e1800b890b84a655ea.json new file mode 100644 index 0000000..45270f2 --- /dev/null +++ b/.sqlx/query-6c71c4ac31f897e9d33a3637d89377c5977f76a117b042e1800b890b84a655ea.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM invite_code_uses WHERE used_by_user = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6c71c4ac31f897e9d33a3637d89377c5977f76a117b042e1800b890b84a655ea" +} diff --git a/.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json b/.sqlx/query-9a8b9c1cfecf02d1266b1544d5cb2dd8f1254b66b884ff22f983a2ba9dee0529.json similarity index 63% rename from .sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json rename to .sqlx/query-9a8b9c1cfecf02d1266b1544d5cb2dd8f1254b66b884ff22f983a2ba9dee0529.json index 474075b..41bd590 100644 --- a/.sqlx/query-7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd.json +++ b/.sqlx/query-9a8b9c1cfecf02d1266b1544d5cb2dd8f1254b66b884ff22f983a2ba9dee0529.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", + "query": "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "7b76e2fcd809a1536465306c79da7985354175e0f025b29c6004dffa310feebd" + "hash": "9a8b9c1cfecf02d1266b1544d5cb2dd8f1254b66b884ff22f983a2ba9dee0529" } diff --git a/.sqlx/query-9f435d95d7c270c82a164c59e9d0caa80ffd7107aff32c806709973fdc6b0020.json b/.sqlx/query-9f435d95d7c270c82a164c59e9d0caa80ffd7107aff32c806709973fdc6b0020.json new file mode 100644 index 0000000..acebd78 --- /dev/null +++ b/.sqlx/query-9f435d95d7c270c82a164c59e9d0caa80ffd7107aff32c806709973fdc6b0020.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, handle FROM users WHERE did = $1 OR handle = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9f435d95d7c270c82a164c59e9d0caa80ffd7107aff32c806709973fdc6b0020" +} diff --git a/.sqlx/query-51809819130908ef3600e5843f6098fb510afb4c827a41bc3a32ad78ea10184c.json b/.sqlx/query-b1c54d3f3e2d3031c0d926ccb0d39a0250320d41d08df65d6d9dcc640451527d.json similarity index 76% rename from .sqlx/query-51809819130908ef3600e5843f6098fb510afb4c827a41bc3a32ad78ea10184c.json rename to .sqlx/query-b1c54d3f3e2d3031c0d926ccb0d39a0250320d41d08df65d6d9dcc640451527d.json index 52737ec..fa8c69c 100644 --- a/.sqlx/query-51809819130908ef3600e5843f6098fb510afb4c827a41bc3a32ad78ea10184c.json +++ b/.sqlx/query-b1c54d3f3e2d3031c0d926ccb0d39a0250320d41d08df65d6d9dcc640451527d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n ", + "query": "\n SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2\n ", "describe": { "columns": [ { @@ -51,6 +51,7 @@ ], "parameters": { "Left": [ + "Int8", "Int8" ] }, @@ -66,5 +67,5 @@ true ] }, - "hash": "51809819130908ef3600e5843f6098fb510afb4c827a41bc3a32ad78ea10184c" + "hash": "b1c54d3f3e2d3031c0d926ccb0d39a0250320d41d08df65d6d9dcc640451527d" } diff --git a/.sqlx/query-b22827038d6041ad1f3b7eae07d77433def15237391fe26004577b12cb7e95b3.json b/.sqlx/query-b22827038d6041ad1f3b7eae07d77433def15237391fe26004577b12cb7e95b3.json new file mode 100644 index 0000000..cb44ecd --- /dev/null +++ b/.sqlx/query-b22827038d6041ad1f3b7eae07d77433def15237391fe26004577b12cb7e95b3.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, did, handle FROM users WHERE did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "handle", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "b22827038d6041ad1f3b7eae07d77433def15237391fe26004577b12cb7e95b3" +} diff --git a/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json b/.sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json similarity index 76% rename from .sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json rename to .sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json index c39151a..9c246a3 100644 --- a/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json +++ b/.sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3" + "hash": "b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7" } diff --git a/.sqlx/query-c583f0016bf5f61c17781f55d121698e81b2314465321a01916ee7902b17e813.json b/.sqlx/query-c583f0016bf5f61c17781f55d121698e81b2314465321a01916ee7902b17e813.json new file mode 100644 index 0000000..388bfca --- /dev/null +++ b/.sqlx/query-c583f0016bf5f61c17781f55d121698e81b2314465321a01916ee7902b17e813.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "c583f0016bf5f61c17781f55d121698e81b2314465321a01916ee7902b17e813" +} diff --git a/.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json b/.sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json similarity index 87% rename from .sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json rename to .sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json index 40a8cfc..8c5b220 100644 --- a/.sqlx/query-3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817.json +++ b/.sqlx/query-e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()", + "query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", "describe": { "columns": [ { @@ -36,5 +36,5 @@ true ] }, - "hash": "3889903e58405370152b9ded229d843c0114e71454ea7da2b212519e98d09817" + "hash": "e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8" } diff --git a/TODO.md b/TODO.md index 9436c1a..130235e 100644 --- a/TODO.md +++ b/TODO.md @@ -253,25 +253,25 @@ Anyway... endpoints for PDS settings not covered by standard ATProto: ### Frontend Views Uses existing ATProto endpoints where possible: -**User Dashboard** +User Dashboard - [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) - [ ] Active sessions view (needs new endpoint or extend existing) - [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) - [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) -**Notification Preferences** +Notification Preferences - [ ] Channel selector (uses `com.bspds.account.*` endpoints above) - [ ] Verification flows for Discord/Telegram/Signal - [ ] Notification history view -**Account Settings** +Account Settings - [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) - [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) - [ ] Handle change (uses `com.atproto.identity.updateHandle`) - [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) - [ ] Data export (uses `com.atproto.sync.getRepo`) -**Admin Dashboard** (privileged users only) +Admin Dashboard (privileged users only) - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination) - [ ] User detail/actions (uses `com.atproto.admin.*` endpoints) - [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`) diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs index 320a124..f69b382 100644 --- a/src/api/actor/preferences.rs +++ b/src/api/actor/preferences.rs @@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; const APP_BSKY_NAMESPACE: &str = "app.bsky"; +const MAX_PREFERENCES_COUNT: usize = 100; +const MAX_PREFERENCE_SIZE: usize = 10_000; #[derive(Serialize)] pub struct GetPreferencesOutput { @@ -141,7 +143,24 @@ pub async fn put_preferences( } }; + if input.preferences.len() > MAX_PREFERENCES_COUNT { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": format!("Too many preferences: {} exceeds limit of {}", input.preferences.len(), MAX_PREFERENCES_COUNT)})), + ) + .into_response(); + } + for pref in &input.preferences { + let pref_str = serde_json::to_string(pref).unwrap_or_default(); + if pref_str.len() > MAX_PREFERENCE_SIZE { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": format!("Preference too large: {} bytes exceeds limit of {}", pref_str.len(), MAX_PREFERENCE_SIZE)})), + ) + .into_response(); + } + let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { Some(t) => t, None => { @@ -200,7 +219,10 @@ pub async fn put_preferences( } for pref in input.preferences { - let pref_type = pref.get("$type").and_then(|t| t.as_str()).unwrap(); + let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { + Some(t) => t, + None => continue, + }; let insert_result = sqlx::query!( "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)", diff --git a/src/api/admin/account.rs b/src/api/admin/account.rs deleted file mode 100644 index e04f673..0000000 --- a/src/api/admin/account.rs +++ /dev/null @@ -1,564 +0,0 @@ -use crate::state::AppState; -use axum::{ - Json, - extract::{Query, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tracing::{error, warn}; - -#[derive(Deserialize)] -pub struct GetAccountInfoParams { - pub did: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AccountInfo { - pub did: String, - pub handle: String, - pub email: Option, - pub indexed_at: String, - pub invite_note: Option, - pub invites_disabled: bool, - pub email_confirmed_at: Option, - pub deactivated_at: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GetAccountInfosOutput { - pub infos: Vec, -} - -pub async fn get_account_info( - State(state): State, - headers: axum::http::HeaderMap, - Query(params): Query, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let did = params.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } - - let result = sqlx::query!( - r#" - SELECT did, handle, email, created_at - FROM users - WHERE did = $1 - "#, - did - ) - .fetch_optional(&state.db) - .await; - - match result { - Ok(Some(row)) => { - ( - StatusCode::OK, - Json(AccountInfo { - did: row.did, - handle: row.handle, - email: Some(row.email), - indexed_at: row.created_at.to_rfc3339(), - invite_note: None, - invites_disabled: false, - email_confirmed_at: None, - deactivated_at: None, - }), - ) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(), - Err(e) => { - error!("DB error in get_account_info: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -pub struct GetAccountInfosParams { - pub dids: String, -} - -pub async fn get_account_infos( - State(state): State, - headers: axum::http::HeaderMap, - Query(params): Query, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); - if dids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "dids is required"})), - ) - .into_response(); - } - - let mut infos = Vec::new(); - - for did in dids { - if did.is_empty() { - continue; - } - - let result = sqlx::query!( - r#" - SELECT did, handle, email, created_at - FROM users - WHERE did = $1 - "#, - did - ) - .fetch_optional(&state.db) - .await; - - if let Ok(Some(row)) = result { - infos.push(AccountInfo { - did: row.did, - handle: row.handle, - email: Some(row.email), - indexed_at: row.created_at.to_rfc3339(), - invite_note: None, - invites_disabled: false, - email_confirmed_at: None, - deactivated_at: None, - }); - } - } - - (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() -} - -#[derive(Deserialize)] -pub struct DeleteAccountInput { - pub did: String, -} - -pub async fn delete_account( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let did = input.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } - - let user = sqlx::query!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await; - - let user_id = match user { - Ok(Some(row)) => row.id, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in delete_account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) - .execute(&state.db) - .await; - - let _ = sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) - .execute(&state.db) - .await; - - let _ = sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) - .execute(&state.db) - .await; - - let _ = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) - .execute(&state.db) - .await; - - let _ = sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) - .execute(&state.db) - .await; - - let result = sqlx::query!("DELETE FROM users WHERE id = $1", user_id) - .execute(&state.db) - .await; - - match result { - Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(), - Err(e) => { - error!("DB error deleting account: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -pub struct UpdateAccountEmailInput { - pub account: String, - pub email: String, -} - -pub async fn update_account_email( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let account = input.account.trim(); - let email = input.email.trim(); - - if account.is_empty() || email.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account and email are required"})), - ) - .into_response(); - } - - let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account) - .execute(&state.db) - .await; - - match result { - Ok(r) => { - if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } - (StatusCode::OK, Json(json!({}))).into_response() - } - Err(e) => { - error!("DB error updating email: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -pub struct UpdateAccountHandleInput { - pub did: String, - pub handle: String, -} - -pub async fn update_account_handle( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let did = input.did.trim(); - let handle = input.handle.trim(); - - if did.is_empty() || handle.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), - ) - .into_response(); - } - - if !handle - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), - ) - .into_response(); - } - - let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did) - .fetch_optional(&state.db) - .await; - - if let Ok(Some(_)) = existing { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), - ) - .into_response(); - } - - let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did) - .execute(&state.db) - .await; - - match result { - Ok(r) => { - if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } - (StatusCode::OK, Json(json!({}))).into_response() - } - Err(e) => { - error!("DB error updating handle: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -pub struct UpdateAccountPasswordInput { - pub did: String, - pub password: String, -} - -pub async fn update_account_password( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let did = input.did.trim(); - let password = input.password.trim(); - - if did.is_empty() || password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did and password are required"})), - ) - .into_response(); - } - - let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) { - Ok(h) => h, - Err(e) => { - error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did) - .execute(&state.db) - .await; - - match result { - Ok(r) => { - if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } - (StatusCode::OK, Json(json!({}))).into_response() - } - Err(e) => { - error!("DB error updating password: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SendEmailInput { - pub recipient_did: String, - pub sender_did: String, - pub content: String, - pub subject: Option, - pub comment: Option, -} - -#[derive(Serialize)] -pub struct SendEmailOutput { - pub sent: bool, -} - -pub async fn send_email( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let auth_header = headers.get("Authorization"); - if auth_header.is_none() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - - let recipient_did = input.recipient_did.trim(); - let content = input.content.trim(); - - if recipient_did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "recipientDid is required"})), - ) - .into_response(); - } - - if content.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "content is required"})), - ) - .into_response(); - } - - let user = sqlx::query!( - "SELECT id, email, handle FROM users WHERE did = $1", - recipient_did - ) - .fetch_optional(&state.db) - .await; - - let (user_id, email, handle) = match user { - Ok(Some(row)) => (row.id, row.email, row.handle), - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in send_email: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let subject = input - .subject - .clone() - .unwrap_or_else(|| format!("Message from {}", hostname)); - - let notification = crate::notifications::NewNotification::email( - user_id, - crate::notifications::NotificationType::AdminEmail, - email, - subject, - content.to_string(), - ); - - let result = crate::notifications::enqueue_notification(&state.db, notification).await; - - match result { - Ok(_) => { - tracing::info!( - "Admin email queued for {} ({})", - handle, - recipient_did - ); - (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() - } - Err(e) => { - warn!("Failed to enqueue admin email: {:?}", e); - (StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response() - } - } -} diff --git a/src/api/admin/account/delete.rs b/src/api/admin/account/delete.rs new file mode 100644 index 0000000..e6c0c21 --- /dev/null +++ b/src/api/admin/account/delete.rs @@ -0,0 +1,190 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use serde_json::json; +use tracing::error; + +#[derive(Deserialize)] +pub struct DeleteAccountInput { + pub did: String, +} + +pub async fn delete_account( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let did = input.did.trim(); + if did.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "did is required"})), + ) + .into_response(); + } + + let user = sqlx::query!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await; + + let user_id = match user { + Ok(Some(row)) => row.id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in delete_account: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction for account deletion: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) + .execute(&mut *tx) + .await + { + error!("Failed to delete session tokens for {}: {:?}", did, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete session tokens"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did) + .execute(&mut *tx) + .await + { + error!("Failed to delete used refresh tokens for {}: {:?}", did, e); + } + + if let Err(e) = sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete records for user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete records"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete repos for user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete repos"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete blobs for user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete blobs"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete app passwords for user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete app passwords"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM invite_code_uses WHERE used_by_user = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete invite code uses for user {}: {:?}", user_id, e); + } + + if let Err(e) = sqlx::query!("DELETE FROM invite_codes WHERE created_by_user = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete invite codes for user {}: {:?}", user_id, e); + } + + if let Err(e) = sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete user keys for user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete user keys"})), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!("DELETE FROM users WHERE id = $1", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to delete user {}: {:?}", user_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to delete user"})), + ) + .into_response(); + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit account deletion transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to commit deletion"})), + ) + .into_response(); + } + + (StatusCode::OK, Json(json!({}))).into_response() +} diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs new file mode 100644 index 0000000..e38556e --- /dev/null +++ b/src/api/admin/account/email.rs @@ -0,0 +1,116 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{error, warn}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendEmailInput { + pub recipient_did: String, + pub sender_did: String, + pub content: String, + pub subject: Option, + pub comment: Option, +} + +#[derive(Serialize)] +pub struct SendEmailOutput { + pub sent: bool, +} + +pub async fn send_email( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let recipient_did = input.recipient_did.trim(); + let content = input.content.trim(); + + if recipient_did.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "recipientDid is required"})), + ) + .into_response(); + } + + if content.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "content is required"})), + ) + .into_response(); + } + + let user = sqlx::query!( + "SELECT id, email, handle FROM users WHERE did = $1", + recipient_did + ) + .fetch_optional(&state.db) + .await; + + let (user_id, email, handle) = match user { + Ok(Some(row)) => (row.id, row.email, row.handle), + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in send_email: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let subject = input + .subject + .clone() + .unwrap_or_else(|| format!("Message from {}", hostname)); + + let notification = crate::notifications::NewNotification::email( + user_id, + crate::notifications::NotificationType::AdminEmail, + email, + subject, + content.to_string(), + ); + + let result = crate::notifications::enqueue_notification(&state.db, notification).await; + + match result { + Ok(_) => { + tracing::info!( + "Admin email queued for {} ({})", + handle, + recipient_did + ); + (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() + } + Err(e) => { + warn!("Failed to enqueue admin email: {:?}", e); + (StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response() + } + } +} diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs new file mode 100644 index 0000000..3b5e3ba --- /dev/null +++ b/src/api/admin/account/info.rs @@ -0,0 +1,164 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::error; + +#[derive(Deserialize)] +pub struct GetAccountInfoParams { + pub did: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountInfo { + pub did: String, + pub handle: String, + pub email: Option, + pub indexed_at: String, + pub invite_note: Option, + pub invites_disabled: bool, + pub email_confirmed_at: Option, + pub deactivated_at: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAccountInfosOutput { + pub infos: Vec, +} + +pub async fn get_account_info( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let did = params.did.trim(); + if did.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "did is required"})), + ) + .into_response(); + } + + let result = sqlx::query!( + r#" + SELECT did, handle, email, created_at + FROM users + WHERE did = $1 + "#, + did + ) + .fetch_optional(&state.db) + .await; + + match result { + Ok(Some(row)) => { + ( + StatusCode::OK, + Json(AccountInfo { + did: row.did, + handle: row.handle, + email: Some(row.email), + indexed_at: row.created_at.to_rfc3339(), + invite_note: None, + invites_disabled: false, + email_confirmed_at: None, + deactivated_at: None, + }), + ) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(), + Err(e) => { + error!("DB error in get_account_info: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +pub struct GetAccountInfosParams { + pub dids: String, +} + +pub async fn get_account_infos( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); + if dids.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "dids is required"})), + ) + .into_response(); + } + + let mut infos = Vec::new(); + + for did in dids { + if did.is_empty() { + continue; + } + + let result = sqlx::query!( + r#" + SELECT did, handle, email, created_at + FROM users + WHERE did = $1 + "#, + did + ) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(row)) = result { + infos.push(AccountInfo { + did: row.did, + handle: row.handle, + email: Some(row.email), + indexed_at: row.created_at.to_rfc3339(), + invite_note: None, + invites_disabled: false, + email_confirmed_at: None, + deactivated_at: None, + }); + } + } + + (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response() +} diff --git a/src/api/admin/account/mod.rs b/src/api/admin/account/mod.rs new file mode 100644 index 0000000..853f126 --- /dev/null +++ b/src/api/admin/account/mod.rs @@ -0,0 +1,15 @@ +mod delete; +mod email; +mod info; +mod update; + +pub use delete::{delete_account, DeleteAccountInput}; +pub use email::{send_email, SendEmailInput, SendEmailOutput}; +pub use info::{ + get_account_info, get_account_infos, AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, + GetAccountInfosParams, +}; +pub use update::{ + update_account_email, update_account_handle, update_account_password, UpdateAccountEmailInput, + UpdateAccountHandleInput, UpdateAccountPasswordInput, +}; diff --git a/src/api/admin/account/update.rs b/src/api/admin/account/update.rs new file mode 100644 index 0000000..5c7d194 --- /dev/null +++ b/src/api/admin/account/update.rs @@ -0,0 +1,216 @@ +use crate::state::AppState; +use axum::{ + Json, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use serde_json::json; +use tracing::error; + +#[derive(Deserialize)] +pub struct UpdateAccountEmailInput { + pub account: String, + pub email: String, +} + +pub async fn update_account_email( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let account = input.account.trim(); + let email = input.email.trim(); + + if account.is_empty() || email.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "account and email are required"})), + ) + .into_response(); + } + + let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() == 0 { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error updating email: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +pub struct UpdateAccountHandleInput { + pub did: String, + pub handle: String, +} + +pub async fn update_account_handle( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let did = input.did.trim(); + let handle = input.handle.trim(); + + if did.is_empty() || handle.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), + ) + .into_response(); + } + + if !handle + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})), + ) + .into_response(); + } + + let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did) + .fetch_optional(&state.db) + .await; + + if let Ok(Some(_)) = existing { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), + ) + .into_response(); + } + + let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() == 0 { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error updating handle: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +pub struct UpdateAccountPasswordInput { + pub did: String, + pub password: String, +} + +pub async fn update_account_password( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let auth_header = headers.get("Authorization"); + if auth_header.is_none() { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "AuthenticationRequired"})), + ) + .into_response(); + } + + let did = input.did.trim(); + let password = input.password.trim(); + + if did.is_empty() || password.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "did and password are required"})), + ) + .into_response(); + } + + let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + error!("Failed to hash password: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() == 0 { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({}))).into_response() + } + Err(e) => { + error!("DB error updating password: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/api/admin/invite.rs b/src/api/admin/invite.rs index 6b26ee7..eae9adb 100644 --- a/src/api/admin/invite.rs +++ b/src/api/admin/invite.rs @@ -104,7 +104,7 @@ pub async fn get_invite_codes( .into_response(); } - let limit = params.limit.unwrap_or(100).min(500); + let limit = params.limit.unwrap_or(100).clamp(1, 500); let sort = params.sort.as_deref().unwrap_or("recent"); let order_clause = match sort { diff --git a/src/api/admin/status.rs b/src/api/admin/status.rs index c77fdb5..c936353 100644 --- a/src/api/admin/status.rs +++ b/src/api/admin/status.rs @@ -234,39 +234,77 @@ pub async fn update_subject_status( Some("com.atproto.admin.defs#repoRef") => { let did = input.subject.get("did").and_then(|d| d.as_str()); if let Some(did) = did { + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + if let Some(takedown) = &input.takedown { let takedown_ref = if takedown.apply { takedown.r#ref.clone() } else { None }; - let _ = sqlx::query!( + if let Err(e) = sqlx::query!( "UPDATE users SET takedown_ref = $1 WHERE did = $2", takedown_ref, did ) - .execute(&state.db) - .await; + .execute(&mut *tx) + .await + { + error!("Failed to update user takedown status for {}: {:?}", did, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), + ) + .into_response(); + } } if let Some(deactivated) = &input.deactivated { - if deactivated.apply { - let _ = sqlx::query!( + let result = if deactivated.apply { + sqlx::query!( "UPDATE users SET deactivated_at = NOW() WHERE did = $1", did ) - .execute(&state.db) - .await; + .execute(&mut *tx) + .await } else { - let _ = sqlx::query!( + sqlx::query!( "UPDATE users SET deactivated_at = NULL WHERE did = $1", did ) - .execute(&state.db) - .await; + .execute(&mut *tx) + .await + }; + + if let Err(e) = result { + error!("Failed to update user deactivation status for {}: {:?}", did, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update deactivation status"})), + ) + .into_response(); } } + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + return ( StatusCode::OK, Json(json!({ @@ -292,13 +330,21 @@ pub async fn update_subject_status( } else { None }; - let _ = sqlx::query!( + if let Err(e) = sqlx::query!( "UPDATE records SET takedown_ref = $1 WHERE record_cid = $2", takedown_ref, uri ) .execute(&state.db) - .await; + .await + { + error!("Failed to update record takedown status for {}: {:?}", uri, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), + ) + .into_response(); + } } return ( @@ -323,13 +369,21 @@ pub async fn update_subject_status( } else { None }; - let _ = sqlx::query!( + if let Err(e) = sqlx::query!( "UPDATE blobs SET takedown_ref = $1 WHERE cid = $2", takedown_ref, cid ) .execute(&state.db) - .await; + .await + { + error!("Failed to update blob takedown status for {}: {:?}", cid, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), + ) + .into_response(); + } } return ( diff --git a/src/api/error.rs b/src/api/error.rs new file mode 100644 index 0000000..f30f2bd --- /dev/null +++ b/src/api/error.rs @@ -0,0 +1,163 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +struct ErrorBody { + error: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +#[derive(Debug)] +pub enum ApiError { + InternalError, + AuthenticationRequired, + AuthenticationFailed, + AuthenticationFailedMsg(String), + InvalidRequest(String), + InvalidToken, + ExpiredToken, + ExpiredTokenMsg(String), + TokenRequired, + AccountDeactivated, + AccountTakedown, + AccountNotFound, + RepoNotFound, + RepoNotFoundMsg(String), + RecordNotFound, + BlobNotFound, + InvalidHandle, + HandleNotAvailable, + HandleTaken, + InvalidEmail, + EmailTaken, + InvalidInviteCode, + DuplicateCreate, + DuplicateAppPassword, + AppPasswordNotFound, + InvalidSwap, + Forbidden, + InvitesDisabled, + DatabaseError, + UpstreamFailure, +} + +impl ApiError { + fn status_code(&self) -> StatusCode { + match self { + Self::InternalError | Self::DatabaseError | Self::UpstreamFailure => { + StatusCode::INTERNAL_SERVER_ERROR + } + Self::AuthenticationRequired + | Self::AuthenticationFailed + | Self::AuthenticationFailedMsg(_) + | Self::InvalidToken + | Self::ExpiredToken + | Self::ExpiredTokenMsg(_) + | Self::TokenRequired + | Self::AccountDeactivated + | Self::AccountTakedown => StatusCode::UNAUTHORIZED, + Self::Forbidden | Self::InvitesDisabled => StatusCode::FORBIDDEN, + Self::AccountNotFound + | Self::RepoNotFound + | Self::RepoNotFoundMsg(_) + | Self::RecordNotFound + | Self::BlobNotFound + | Self::AppPasswordNotFound => StatusCode::NOT_FOUND, + Self::InvalidRequest(_) + | Self::InvalidHandle + | Self::HandleNotAvailable + | Self::HandleTaken + | Self::InvalidEmail + | Self::EmailTaken + | Self::InvalidInviteCode + | Self::DuplicateCreate + | Self::DuplicateAppPassword + | Self::InvalidSwap => StatusCode::BAD_REQUEST, + } + } + + fn error_name(&self) -> &'static str { + match self { + Self::InternalError | Self::DatabaseError | Self::UpstreamFailure => "InternalError", + Self::AuthenticationRequired => "AuthenticationRequired", + Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed", + Self::InvalidToken => "InvalidToken", + Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken", + Self::TokenRequired => "TokenRequired", + Self::AccountDeactivated => "AccountDeactivated", + Self::AccountTakedown => "AccountTakedown", + Self::Forbidden => "Forbidden", + Self::InvitesDisabled => "InvitesDisabled", + Self::AccountNotFound => "AccountNotFound", + Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound", + Self::RecordNotFound => "RecordNotFound", + Self::BlobNotFound => "BlobNotFound", + Self::AppPasswordNotFound => "AppPasswordNotFound", + Self::InvalidRequest(_) => "InvalidRequest", + Self::InvalidHandle => "InvalidHandle", + Self::HandleNotAvailable => "HandleNotAvailable", + Self::HandleTaken => "HandleTaken", + Self::InvalidEmail => "InvalidEmail", + Self::EmailTaken => "EmailTaken", + Self::InvalidInviteCode => "InvalidInviteCode", + Self::DuplicateCreate => "DuplicateCreate", + Self::DuplicateAppPassword => "DuplicateAppPassword", + Self::InvalidSwap => "InvalidSwap", + } + } + + fn message(&self) -> Option { + match self { + Self::AuthenticationFailedMsg(msg) + | Self::ExpiredTokenMsg(msg) + | Self::InvalidRequest(msg) + | Self::RepoNotFoundMsg(msg) => Some(msg.clone()), + _ => None, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let body = ErrorBody { + error: self.error_name(), + message: self.message(), + }; + (self.status_code(), Json(body)).into_response() + } +} + +impl From for ApiError { + fn from(e: sqlx::Error) -> Self { + tracing::error!("Database error: {:?}", e); + Self::DatabaseError + } +} + +impl From for ApiError { + fn from(e: crate::auth::TokenValidationError) -> Self { + match e { + crate::auth::TokenValidationError::AccountDeactivated => Self::AccountDeactivated, + crate::auth::TokenValidationError::AccountTakedown => Self::AccountTakedown, + crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError, + crate::auth::TokenValidationError::AuthenticationFailed => Self::AuthenticationFailed, + } + } +} + +impl From for ApiError { + fn from(e: crate::util::DbLookupError) -> Self { + match e { + crate::util::DbLookupError::NotFound => Self::AccountNotFound, + crate::util::DbLookupError::DatabaseError(db_err) => { + tracing::error!("Database error: {:?}", db_err); + Self::DatabaseError + } + } + } +} diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 269ee23..be166b2 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -40,7 +40,7 @@ pub async fn create_account( State(state): State, Json(input): Json, ) -> Response { - info!("create_account hit: {}", input.handle); + info!("create_account called"); if input.handle.contains('!') || input.handle.contains('@') { return ( StatusCode::BAD_REQUEST, @@ -51,6 +51,14 @@ pub async fn create_account( .into_response(); } + if !crate::api::validation::is_valid_email(&input.email) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), + ) + .into_response(); + } + let did = if let Some(d) = &input.did { if d.trim().is_empty() { format!("did:plc:{}", uuid::Uuid::new_v4()) diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index 15f5d6a..57cfbca 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -1,3 +1,4 @@ +use crate::api::ApiError; use crate::state::AppState; use axum::{ Json, @@ -56,19 +57,21 @@ pub async fn resolve_handle( } } -pub fn get_jwk(key_bytes: &[u8]) -> serde_json::Value { - let secret_key = SecretKey::from_slice(key_bytes).expect("Invalid key length"); +pub fn get_jwk(key_bytes: &[u8]) -> Result { + let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?; let public_key = secret_key.public_key(); let encoded = public_key.to_encoded_point(false); - let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(encoded.x().unwrap()); - let y = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(encoded.y().unwrap()); + let x = encoded.x().ok_or("Missing x coordinate")?; + let y = encoded.y().ok_or("Missing y coordinate")?; + let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(x); + let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(y); - json!({ + Ok(json!({ "kty": "EC", "crv": "secp256k1", - "x": x, - "y": y - }) + "x": x_b64, + "y": y_b64 + })) } pub async fn well_known_did(State(_state): State) -> impl IntoResponse { @@ -147,7 +150,17 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path j, + Err(e) => { + tracing::error!("Failed to generate JWK: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; Json(json!({ "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"], @@ -294,42 +307,22 @@ pub async fn get_recommended_did_credentials( } }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(ref user) => user.did.clone(), - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), }; - let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", did) + let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", auth_user.did) .fetch_optional(&state.db) .await { Ok(Some(row)) => row, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + _ => return ApiError::InternalError.into_response(), }; - let handle = user.handle; - let key_bytes = match auth_result.ok().and_then(|u| u.key_bytes) { + let key_bytes = match auth_user.key_bytes { Some(kb) => kb, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot get DID credentials"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot get DID credentials".into()).into_response(), }; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -337,13 +330,7 @@ pub async fn get_recommended_did_credentials( let secret_key = match k256::SecretKey::from_slice(&key_bytes) { Ok(k) => k, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + Err(_) => return ApiError::InternalError.into_response(), }; let public_key = secret_key.public_key(); @@ -360,7 +347,7 @@ pub async fn get_recommended_did_credentials( StatusCode::OK, Json(GetRecommendedDidCredentialsOutput { rotation_keys: vec![did_key.clone()], - also_known_as: vec![format!("at://{}", handle)], + also_known_as: vec![format!("at://{}", user.handle)], verification_methods: VerificationMethods { atproto: did_key }, services: Services { atproto_pds: AtprotoPds { @@ -387,25 +374,12 @@ pub async fn update_handle( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) @@ -413,22 +387,12 @@ pub async fn update_handle( .await { Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + _ => return ApiError::InternalError.into_response(), }; let new_handle = input.handle.trim(); if new_handle.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "handle is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("handle is required".into()).into_response(); } if !new_handle diff --git a/src/api/identity/plc.rs b/src/api/identity/plc.rs deleted file mode 100644 index 84290e5..0000000 --- a/src/api/identity/plc.rs +++ /dev/null @@ -1,618 +0,0 @@ -use crate::plc::{ - create_update_op, sign_operation, signing_key_to_did_key, validate_plc_operation, - PlcClient, PlcError, PlcService, -}; -use crate::state::AppState; -use axum::{ - extract::State, - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use chrono::{Duration, Utc}; -use k256::ecdsa::SigningKey; -use rand::Rng; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use tracing::{error, info, warn}; - -fn generate_plc_token() -> String { - let mut rng = rand::thread_rng(); - let chars: Vec = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); - let part1: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - let part2: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - format!("{}-{}", part1, part2) -} - -pub async fn request_plc_operation_signature( - State(state): State, - headers: axum::http::HeaderMap, -) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { - Ok(user) => user, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": e})), - ) - .into_response(); - } - }; - - let did = &auth_user.did; - - let user = match sqlx::query!( - "SELECT id FROM users WHERE did = $1", - did - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let _ = sqlx::query!( - "DELETE FROM plc_operation_tokens WHERE user_id = $1 OR expires_at < NOW()", - user.id - ) - .execute(&state.db) - .await; - - let plc_token = generate_plc_token(); - let expires_at = Utc::now() + Duration::minutes(10); - - if let Err(e) = sqlx::query!( - r#" - INSERT INTO plc_operation_tokens (user_id, token, expires_at) - VALUES ($1, $2, $3) - "#, - user.id, - plc_token, - expires_at - ) - .execute(&state.db) - .await - { - error!("Failed to create PLC token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - - if let Err(e) = crate::notifications::enqueue_plc_operation( - &state.db, - user.id, - &plc_token, - &hostname, - ) - .await - { - warn!("Failed to enqueue PLC operation notification: {:?}", e); - } - - info!("PLC operation signature requested for user {}", did); - - (StatusCode::OK, Json(json!({}))).into_response() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SignPlcOperationInput { - pub token: Option, - pub rotation_keys: Option>, - pub also_known_as: Option>, - pub verification_methods: Option>, - pub services: Option>, -} - -#[derive(Debug, Deserialize, Clone)] -pub struct ServiceInput { - #[serde(rename = "type")] - pub service_type: String, - pub endpoint: String, -} - -#[derive(Debug, Serialize)] -pub struct SignPlcOperationOutput { - pub operation: Value, -} - -pub async fn sign_plc_operation( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let bearer = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { - Ok(user) => user, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": e})), - ) - .into_response(); - } - }; - - let did = &auth_user.did; - - let token = match &input.token { - Some(t) => t, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Email confirmation token required to sign PLC operations" - })), - ) - .into_response(); - } - }; - - let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); - } - }; - - let token_row = match sqlx::query!( - "SELECT id, expires_at FROM plc_operation_tokens WHERE user_id = $1 AND token = $2", - user.id, - token - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidToken", - "message": "Invalid or expired token" - })), - ) - .into_response(); - } - Err(e) => { - error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - if Utc::now() > token_row.expires_at { - let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) - .execute(&state.db) - .await; - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "ExpiredToken", - "message": "Token has expired" - })), - ) - .into_response(); - } - - let key_row = match sqlx::query!( - "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", - user.id - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User signing key not found"})), - ) - .into_response(); - } - }; - - let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) - { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt user key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let signing_key = match SigningKey::from_slice(&key_bytes) { - Ok(k) => k, - Err(e) => { - error!("Failed to create signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let plc_client = PlcClient::new(None); - let last_op = match plc_client.get_last_op(did).await { - Ok(op) => op, - Err(PlcError::NotFound) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ - "error": "NotFound", - "message": "DID not found in PLC directory" - })), - ) - .into_response(); - } - Err(e) => { - error!("Failed to fetch PLC operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": "Failed to communicate with PLC directory" - })), - ) - .into_response(); - } - }; - - if last_op.is_tombstone() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID is tombstoned" - })), - ) - .into_response(); - } - - let services = input.services.map(|s| { - s.into_iter() - .map(|(k, v)| { - ( - k, - PlcService { - service_type: v.service_type, - endpoint: v.endpoint, - }, - ) - }) - .collect() - }); - - let unsigned_op = match create_update_op( - &last_op, - input.rotation_keys, - input.verification_methods, - input.also_known_as, - services, - ) { - Ok(op) => op, - Err(PlcError::Tombstoned) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Cannot update tombstoned DID" - })), - ) - .into_response(); - } - Err(e) => { - error!("Failed to create PLC operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let signed_op = match sign_operation(&unsigned_op, &signing_key) { - Ok(op) => op, - Err(e) => { - error!("Failed to sign PLC operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) - .execute(&state.db) - .await; - - info!("Signed PLC operation for user {}", did); - - ( - StatusCode::OK, - Json(SignPlcOperationOutput { - operation: signed_op, - }), - ) - .into_response() -} - -#[derive(Debug, Deserialize)] -pub struct SubmitPlcOperationInput { - pub operation: Value, -} - -pub async fn submit_plc_operation( - State(state): State, - headers: axum::http::HeaderMap, - Json(input): Json, -) -> Response { - let bearer = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { - Ok(user) => user, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": e})), - ) - .into_response(); - } - }; - - let did = &auth_user.did; - - if let Err(e) = validate_plc_operation(&input.operation) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid operation: {}", e) - })), - ) - .into_response(); - } - - let op = &input.operation; - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let public_url = format!("https://{}", hostname); - - let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); - } - }; - - let key_row = match sqlx::query!( - "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", - user.id - ) - .fetch_optional(&state.db) - .await - { - Ok(Some(row)) => row, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User signing key not found"})), - ) - .into_response(); - } - }; - - let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) - { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt user key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let signing_key = match SigningKey::from_slice(&key_bytes) { - Ok(k) => k, - Err(e) => { - error!("Failed to create signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let user_did_key = signing_key_to_did_key(&signing_key); - - if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { - let server_rotation_key = - std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); - - let has_server_key = rotation_keys - .iter() - .any(|k| k.as_str() == Some(&server_rotation_key)); - - if !has_server_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Rotation keys do not include server's rotation key" - })), - ) - .into_response(); - } - } - - if let Some(services) = op.get("services").and_then(|v| v.as_object()) { - if let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) { - let service_type = pds.get("type").and_then(|v| v.as_str()); - let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); - - if service_type != Some("AtprotoPersonalDataServer") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect type on atproto_pds service" - })), - ) - .into_response(); - } - - if endpoint != Some(&public_url) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect endpoint on atproto_pds service" - })), - ) - .into_response(); - } - } - } - - if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) { - if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) { - if atproto_key != user_did_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect signing key in verificationMethods" - })), - ) - .into_response(); - } - } - } - - if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { - let expected_handle = format!("at://{}", user.handle); - let first_aka = also_known_as.first().and_then(|v| v.as_str()); - - if first_aka != Some(&expected_handle) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect handle in alsoKnownAs" - })), - ) - .into_response(); - } - } - - let plc_client = PlcClient::new(None); - if let Err(e) = plc_client.send_operation(did, &input.operation).await { - error!("Failed to submit PLC operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to submit to PLC directory: {}", e) - })), - ) - .into_response(); - } - - if let Err(e) = sqlx::query!( - "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity')", - did - ) - .execute(&state.db) - .await - { - warn!("Failed to sequence identity event: {:?}", e); - } - - info!("Submitted PLC operation for user {}", did); - - (StatusCode::OK, Json(json!({}))).into_response() -} diff --git a/src/api/identity/plc/mod.rs b/src/api/identity/plc/mod.rs new file mode 100644 index 0000000..9dc1609 --- /dev/null +++ b/src/api/identity/plc/mod.rs @@ -0,0 +1,7 @@ +mod request; +mod sign; +mod submit; + +pub use request::request_plc_operation_signature; +pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput}; +pub use submit::{submit_plc_operation, SubmitPlcOperationInput}; diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs new file mode 100644 index 0000000..ce889f5 --- /dev/null +++ b/src/api/identity/plc/request.rs @@ -0,0 +1,91 @@ +use crate::api::ApiError; +use crate::state::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use chrono::{Duration, Utc}; +use serde_json::json; +use tracing::{error, info, warn}; + +fn generate_plc_token() -> String { + crate::util::generate_token_code() +} + +pub async fn request_plc_operation_signature( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + Ok(None) => return ApiError::AccountNotFound.into_response(), + Err(e) => { + error!("DB error: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let _ = sqlx::query!( + "DELETE FROM plc_operation_tokens WHERE user_id = $1 OR expires_at < NOW()", + user.id + ) + .execute(&state.db) + .await; + + let plc_token = generate_plc_token(); + let expires_at = Utc::now() + Duration::minutes(10); + + if let Err(e) = sqlx::query!( + r#" + INSERT INTO plc_operation_tokens (user_id, token, expires_at) + VALUES ($1, $2, $3) + "#, + user.id, + plc_token, + expires_at + ) + .execute(&state.db) + .await + { + error!("Failed to create PLC token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + + if let Err(e) = crate::notifications::enqueue_plc_operation( + &state.db, + user.id, + &plc_token, + &hostname, + ) + .await + { + warn!("Failed to enqueue PLC operation notification: {:?}", e); + } + + info!("PLC operation signature requested for user {}", auth_user.did); + + (StatusCode::OK, Json(json!({}))).into_response() +} diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs new file mode 100644 index 0000000..efb57aa --- /dev/null +++ b/src/api/identity/plc/sign.rs @@ -0,0 +1,272 @@ +use crate::api::ApiError; +use crate::plc::{ + create_update_op, sign_operation, PlcClient, PlcError, PlcService, +}; +use crate::state::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use chrono::Utc; +use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use tracing::{error, info}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SignPlcOperationInput { + pub token: Option, + pub rotation_keys: Option>, + pub also_known_as: Option>, + pub verification_methods: Option>, + pub services: Option>, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServiceInput { + #[serde(rename = "type")] + pub service_type: String, + pub endpoint: String, +} + +#[derive(Debug, Serialize)] +pub struct SignPlcOperationOutput { + pub operation: Value, +} + +pub async fn sign_plc_operation( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let bearer = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + + let did = &auth_user.did; + + let token = match &input.token { + Some(t) => t, + None => { + return ApiError::InvalidRequest( + "Email confirmation token required to sign PLC operations".into() + ).into_response(); + } + }; + + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(); + } + }; + + let token_row = match sqlx::query!( + "SELECT id, expires_at FROM plc_operation_tokens WHERE user_id = $1 AND token = $2", + user.id, + token + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidToken", + "message": "Invalid or expired token" + })), + ) + .into_response(); + } + Err(e) => { + error!("DB error: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if Utc::now() > token_row.expires_at { + let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) + .execute(&state.db) + .await; + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "ExpiredToken", + "message": "Token has expired" + })), + ) + .into_response(); + } + + let key_row = match sqlx::query!( + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", + user.id + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User signing key not found"})), + ) + .into_response(); + } + }; + + let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) + { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let signing_key = match SigningKey::from_slice(&key_bytes) { + Ok(k) => k, + Err(e) => { + error!("Failed to create signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let plc_client = PlcClient::new(None); + let last_op = match plc_client.get_last_op(did).await { + Ok(op) => op, + Err(PlcError::NotFound) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": "NotFound", + "message": "DID not found in PLC directory" + })), + ) + .into_response(); + } + Err(e) => { + error!("Failed to fetch PLC operation: {:?}", e); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "error": "UpstreamError", + "message": "Failed to communicate with PLC directory" + })), + ) + .into_response(); + } + }; + + if last_op.is_tombstone() { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "DID is tombstoned" + })), + ) + .into_response(); + } + + let services = input.services.map(|s| { + s.into_iter() + .map(|(k, v)| { + ( + k, + PlcService { + service_type: v.service_type, + endpoint: v.endpoint, + }, + ) + }) + .collect() + }); + + let unsigned_op = match create_update_op( + &last_op, + input.rotation_keys, + input.verification_methods, + input.also_known_as, + services, + ) { + Ok(op) => op, + Err(PlcError::Tombstoned) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Cannot update tombstoned DID" + })), + ) + .into_response(); + } + Err(e) => { + error!("Failed to create PLC operation: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let signed_op = match sign_operation(&unsigned_op, &signing_key) { + Ok(op) => op, + Err(e) => { + error!("Failed to sign PLC operation: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id) + .execute(&state.db) + .await; + + info!("Signed PLC operation for user {}", did); + + ( + StatusCode::OK, + Json(SignPlcOperationOutput { + operation: signed_op, + }), + ) + .into_response() +} diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs new file mode 100644 index 0000000..d91f4c5 --- /dev/null +++ b/src/api/identity/plc/submit.rs @@ -0,0 +1,211 @@ +use crate::api::ApiError; +use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient}; +use crate::state::AppState; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use k256::ecdsa::SigningKey; +use serde::Deserialize; +use serde_json::{json, Value}; +use tracing::{error, info, warn}; + +#[derive(Debug, Deserialize)] +pub struct SubmitPlcOperationInput { + pub operation: Value, +} + +pub async fn submit_plc_operation( + State(state): State, + headers: axum::http::HeaderMap, + Json(input): Json, +) -> Response { + let bearer = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()), + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &bearer).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + + let did = &auth_user.did; + + if let Err(e) = validate_plc_operation(&input.operation) { + return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response(); + } + + let op = &input.operation; + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let public_url = format!("https://{}", hostname); + + let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(); + } + }; + + let key_row = match sqlx::query!( + "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", + user.id + ) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "User signing key not found"})), + ) + .into_response(); + } + }; + + let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) + { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let signing_key = match SigningKey::from_slice(&key_bytes) { + Ok(k) => k, + Err(e) => { + error!("Failed to create signing key: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + let user_did_key = signing_key_to_did_key(&signing_key); + + if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) { + let server_rotation_key = + std::env::var("PLC_ROTATION_KEY").unwrap_or_else(|_| user_did_key.clone()); + + let has_server_key = rotation_keys + .iter() + .any(|k| k.as_str() == Some(&server_rotation_key)); + + if !has_server_key { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Rotation keys do not include server's rotation key" + })), + ) + .into_response(); + } + } + + if let Some(services) = op.get("services").and_then(|v| v.as_object()) { + if let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) { + let service_type = pds.get("type").and_then(|v| v.as_str()); + let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); + + if service_type != Some("AtprotoPersonalDataServer") { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect type on atproto_pds service" + })), + ) + .into_response(); + } + + if endpoint != Some(&public_url) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect endpoint on atproto_pds service" + })), + ) + .into_response(); + } + } + } + + if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) { + if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) { + if atproto_key != user_did_key { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect signing key in verificationMethods" + })), + ) + .into_response(); + } + } + } + + if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) { + let expected_handle = format!("at://{}", user.handle); + let first_aka = also_known_as.first().and_then(|v| v.as_str()); + + if first_aka != Some(&expected_handle) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidRequest", + "message": "Incorrect handle in alsoKnownAs" + })), + ) + .into_response(); + } + } + + let plc_client = PlcClient::new(None); + if let Err(e) = plc_client.send_operation(did, &input.operation).await { + error!("Failed to submit PLC operation: {:?}", e); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "error": "UpstreamError", + "message": format!("Failed to submit to PLC directory: {}", e) + })), + ) + .into_response(); + } + + if let Err(e) = sqlx::query!( + "INSERT INTO repo_seq (did, event_type) VALUES ($1, 'identity')", + did + ) + .execute(&state.db) + .await + { + warn!("Failed to sequence identity event: {:?}", e); + } + + info!("Submitted PLC operation for user {}", did); + + (StatusCode::OK, Json(json!({}))).into_response() +} diff --git a/src/api/mod.rs b/src/api/mod.rs index aa943dc..ebd7535 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,8 +1,12 @@ pub mod actor; pub mod admin; +pub mod error; pub mod feed; pub mod identity; pub mod moderation; pub mod proxy; pub mod repo; pub mod server; +pub mod validation; + +pub use error::ApiError; diff --git a/src/api/moderation/mod.rs b/src/api/moderation/mod.rs index ce617b5..f6ba7e5 100644 --- a/src/api/moderation/mod.rs +++ b/src/api/moderation/mod.rs @@ -1,3 +1,4 @@ +use crate::api::ApiError; use crate::state::AppState; use axum::{ Json, @@ -37,25 +38,12 @@ pub async fn create_report( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let valid_reason_types = [ diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index df8f581..37fc3ed 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -15,11 +15,21 @@ use sha2::{Digest, Sha256}; use std::str::FromStr; use tracing::error; +const MAX_BLOB_SIZE: usize = 1_000_000; + pub async fn upload_blob( State(state): State, headers: axum::http::HeaderMap, body: Bytes, ) -> Response { + if body.len() > MAX_BLOB_SIZE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", body.len(), MAX_BLOB_SIZE)})), + ) + .into_response(); + } + let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { @@ -57,7 +67,17 @@ pub async fn upload_blob( let mut hasher = Sha256::new(); hasher.update(&data); let hash = hasher.finalize(); - let multihash = Multihash::wrap(0x12, &hash).unwrap(); + let multihash = match Multihash::wrap(0x12, &hash) { + Ok(mh) => mh, + Err(e) => { + error!("Failed to create multihash for blob: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": "Failed to hash blob"})), + ) + .into_response(); + } + }; let cid = Cid::new_v1(0x55, multihash); let cid_str = cid.to_string(); @@ -207,7 +227,7 @@ pub async fn list_missing_blobs( } }; - let limit = params.limit.unwrap_or(500).min(1000); + let limit = params.limit.unwrap_or(500).clamp(1, 1000); let cursor_str = params.cursor.unwrap_or_default(); let (cursor_collection, cursor_rkey) = if cursor_str.contains('|') { let parts: Vec<&str> = cursor_str.split('|').collect(); diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index 4c788f2..8b6f6b2 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -1,3 +1,4 @@ +use crate::api::ApiError; use crate::state::AppState; use crate::sync::import::{apply_import, parse_car, ImportError}; use crate::sync::verify::CarVerifier; @@ -54,24 +55,12 @@ pub async fn import_repo( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(user) => user, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let did = &auth_user.did; diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 3c88124..86c87b4 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -17,6 +17,8 @@ use std::str::FromStr; use std::sync::Arc; use tracing::error; +const MAX_BATCH_WRITES: usize = 200; + #[derive(Deserialize)] #[serde(tag = "$type")] pub enum WriteOp { @@ -115,10 +117,10 @@ pub async fn apply_writes( .into_response(); } - if input.writes.len() > 200 { + if input.writes.len() > MAX_BATCH_WRITES { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Too many writes (max 200)"})), + Json(json!({"error": "InvalidRequest", "message": format!("Too many writes (max {})", MAX_BATCH_WRITES)})), ) .into_response(); } @@ -213,11 +215,23 @@ pub async fn apply_writes( .clone() .unwrap_or_else(|| Utc::now().format("%Y%m%d%H%M%S%f").to_string()); let mut record_bytes = Vec::new(); - serde_ipld_dagcbor::to_writer(&mut record_bytes, value).unwrap(); - let record_cid = tracking_store.put(&record_bytes).await.unwrap(); + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + } + let record_cid = match tracking_store.put(&record_bytes).await { + Ok(c) => c, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(), + }; - let key = format!("{}/{}", collection.parse::().unwrap(), rkey); - mst = mst.add(&key, record_cid).await.unwrap(); + let collection_nsid = match collection.parse::() { + Ok(n) => n, + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + }; + let key = format!("{}/{}", collection_nsid, rkey); + mst = match mst.add(&key, record_cid).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(), + }; let uri = format!("at://{}/{}/{}", did, collection, rkey); results.push(WriteResult::CreateResult { @@ -236,11 +250,23 @@ pub async fn apply_writes( value, } => { let mut record_bytes = Vec::new(); - serde_ipld_dagcbor::to_writer(&mut record_bytes, value).unwrap(); - let record_cid = tracking_store.put(&record_bytes).await.unwrap(); + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + } + let record_cid = match tracking_store.put(&record_bytes).await { + Ok(c) => c, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(), + }; - let key = format!("{}/{}", collection.parse::().unwrap(), rkey); - mst = mst.update(&key, record_cid).await.unwrap(); + let collection_nsid = match collection.parse::() { + Ok(n) => n, + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + }; + let key = format!("{}/{}", collection_nsid, rkey); + mst = match mst.update(&key, record_cid).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(), + }; let uri = format!("at://{}/{}/{}", did, collection, rkey); results.push(WriteResult::UpdateResult { @@ -254,8 +280,15 @@ pub async fn apply_writes( }); } WriteOp::Delete { collection, rkey } => { - let key = format!("{}/{}", collection.parse::().unwrap(), rkey); - mst = mst.delete(&key).await.unwrap(); + let collection_nsid = match collection.parse::() { + Ok(n) => n, + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + }; + let key = format!("{}/{}", collection_nsid, rkey); + mst = match mst.delete(&key).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to delete from MST"}))).into_response(), + }; results.push(WriteResult::DeleteResult {}); ops.push(RecordOp::Delete { @@ -266,7 +299,10 @@ pub async fn apply_writes( } } - let new_mst_root = mst.persist().await.unwrap(); + let new_mst_root = match mst.persist().await { + Ok(c) => c, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(), + }; let written_cids = tracking_store.get_written_cids(); let written_cids_str = written_cids .iter() diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs index 7270715..b5fae51 100644 --- a/src/api/repo/record/utils.rs +++ b/src/api/repo/record/utils.rs @@ -55,8 +55,11 @@ pub async fn commit_and_log( let new_root_cid = state.block_store.put(&new_commit_bytes).await .map_err(|e| format!("Failed to save commit block: {:?}", e))?; + let mut tx = state.db.begin().await + .map_err(|e| format!("Failed to begin transaction: {}", e))?; + sqlx::query!("UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), user_id) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(|e| format!("DB Error (repos): {}", e))?; @@ -71,7 +74,7 @@ pub async fn commit_and_log( rkey, cid.to_string() ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(|e| format!("DB Error (records): {}", e))?; } @@ -82,7 +85,7 @@ pub async fn commit_and_log( collection, rkey ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(|e| format!("DB Error (records): {}", e))?; } @@ -126,17 +129,20 @@ pub async fn commit_and_log( &[] as &[String], blocks_cids, ) - .fetch_one(&state.db) + .fetch_one(&mut *tx) .await .map_err(|e| format!("DB Error (repo_seq): {}", e))?; sqlx::query( &format!("NOTIFY repo_updates, '{}'", seq_row.seq) ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(|e| format!("DB Error (notify): {}", e))?; + tx.commit().await + .map_err(|e| format!("Failed to commit transaction: {}", e))?; + Ok(CommitResult { commit_cid: new_root_cid, rev: rev.to_string(), diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index ff01c97..f8d7837 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -294,11 +294,20 @@ pub async fn put_record( }; let new_mst = if existing_cid.is_some() { - mst.update(&key, record_cid).await.unwrap() + match mst.update(&key, record_cid).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(), + } } else { - mst.add(&key, record_cid).await.unwrap() + match mst.add(&key, record_cid).await { + Ok(m) => m, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(), + } + }; + let new_mst_root = match new_mst.persist().await { + Ok(c) => c, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(), }; - let new_mst_root = new_mst.persist().await.unwrap(); let op = if existing_cid.is_some() { RecordOp::Update { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid } diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index ae5e65f..b877563 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -1,3 +1,4 @@ +use crate::api::ApiError; use crate::state::AppState; use axum::{ Json, @@ -34,25 +35,12 @@ pub async fn check_account_status( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) @@ -127,25 +115,12 @@ pub async fn activate_account( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) @@ -180,25 +155,12 @@ pub async fn deactivate_account( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did) @@ -226,25 +188,12 @@ pub async fn request_account_delete( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let auth_result = crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await; - let did = match auth_result { + let did = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index a599322..8dff8d8 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -1,8 +1,10 @@ +use crate::api::ApiError; +use crate::auth::BearerAuth; use crate::state::AppState; +use crate::util::get_user_id_by_did; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; @@ -24,73 +26,35 @@ pub struct ListAppPasswordsOutput { pub async fn list_app_passwords( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await + match sqlx::query!( + "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", + user_id + ) + .fetch_all(&state.db) + .await { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let result = sqlx::query!("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", user_id) - .fetch_all(&state.db) - .await; - - match result { Ok(rows) => { let passwords: Vec = rows .iter() - .map(|row| { - AppPassword { - name: row.name.clone(), - created_at: row.created_at.to_rfc3339(), - privileged: row.privileged, - } + .map(|row| AppPassword { + name: row.name.clone(), + created_at: row.created_at.to_rfc3339(), + privileged: row.privileged, }) .collect(); - (StatusCode::OK, Json(ListAppPasswordsOutput { passwords })).into_response() + Json(ListAppPasswordsOutput { passwords }).into_response() } Err(e) => { error!("DB error listing app passwords: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError.into_response() } } } @@ -112,67 +76,29 @@ pub struct CreateAppPasswordOutput { pub async fn create_app_password( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, Json(input): Json, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; let name = input.name.trim(); if name.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "name is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("name is required".into()).into_response(); } - let existing = sqlx::query!("SELECT id FROM app_passwords WHERE user_id = $1 AND name = $2", user_id, name) - .fetch_optional(&state.db) - .await; + let existing = sqlx::query!( + "SELECT id FROM app_passwords WHERE user_id = $1 AND name = $2", + user_id, + name + ) + .fetch_optional(&state.db) + .await; if let Ok(Some(_)) = existing { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "DuplicateAppPassword", "message": "App password with this name already exists"})), - ) - .into_response(); + return ApiError::DuplicateAppPassword.into_response(); } let password: String = (0..4) @@ -180,7 +106,9 @@ pub async fn create_app_password( use rand::Rng; let mut rng = rand::thread_rng(); let chars: Vec = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); - (0..4).map(|_| chars[rng.gen_range(0..chars.len())]).collect::() + (0..4) + .map(|_| chars[rng.gen_range(0..chars.len())]) + .collect::() }) .collect::>() .join("-"); @@ -189,18 +117,14 @@ pub async fn create_app_password( Ok(h) => h, Err(e) => { error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } }; let privileged = input.privileged.unwrap_or(false); let created_at = chrono::Utc::now(); - let result = sqlx::query!( + match sqlx::query!( "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)", user_id, name, @@ -209,26 +133,18 @@ pub async fn create_app_password( privileged ) .execute(&state.db) - .await; - - match result { - Ok(_) => ( - StatusCode::OK, - Json(CreateAppPasswordOutput { - name: name.to_string(), - password, - created_at: created_at.to_rfc3339(), - privileged, - }), - ) - .into_response(), + .await + { + Ok(_) => Json(CreateAppPasswordOutput { + name: name.to_string(), + password, + created_at: created_at.to_rfc3339(), + privileged, + }) + .into_response(), Err(e) => { error!("DB error creating app password: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError.into_response() } } } @@ -240,79 +156,36 @@ pub struct RevokeAppPasswordInput { pub async fn revoke_app_password( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, Json(input): Json, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; let name = input.name.trim(); if name.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "name is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("name is required".into()).into_response(); } - let result = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", user_id, name) - .execute(&state.db) - .await; - - match result { + match sqlx::query!( + "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", + user_id, + name + ) + .execute(&state.db) + .await + { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AppPasswordNotFound", "message": "App password not found"})), - ) - .into_response(); + return ApiError::AppPasswordNotFound.into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + Json(json!({})).into_response() } Err(e) => { error!("DB error revoking app password: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError.into_response() } } } diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 03ec402..3905632 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -1,3 +1,4 @@ +use crate::api::ApiError; use crate::state::AppState; use axum::{ Json, @@ -6,17 +7,12 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::{Duration, Utc}; -use rand::Rng; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; fn generate_confirmation_code() -> String { - let mut rng = rand::thread_rng(); - let chars: Vec = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); - let part1: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - let part2: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - format!("{}-{}", part1, part2) + crate::util::generate_token_code() } #[derive(Deserialize)] @@ -46,13 +42,7 @@ pub async fn request_email_update( let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) @@ -72,10 +62,10 @@ pub async fn request_email_update( let handle = user.handle; let email = input.email.trim().to_lowercase(); - if email.is_empty() { + if !crate::api::validation::is_valid_email(&email) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "email is required"})), + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), ) .into_response(); } @@ -161,13 +151,7 @@ pub async fn confirm_email( let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user = match sqlx::query!( @@ -194,16 +178,18 @@ pub async fn confirm_email( let email = input.email.trim().to_lowercase(); let confirmation_code = input.token.trim(); - if email_pending_verification.is_none() || stored_code.is_none() || expires_at.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), - ) - .into_response(); - } + let (pending_email, saved_code, expiry) = match (email_pending_verification, stored_code, expires_at) { + (Some(p), Some(c), Some(e)) => (p, c, e), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + ) + .into_response(); + } + }; - let email_pending_verification = email_pending_verification.unwrap(); - if email_pending_verification != email { + if pending_email != email { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})), @@ -211,7 +197,7 @@ pub async fn confirm_email( .into_response(); } - if stored_code.unwrap() != confirmation_code { + if saved_code != confirmation_code { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidToken", "message": "Invalid token"})), @@ -219,7 +205,7 @@ pub async fn confirm_email( .into_response(); } - if Utc::now() > expires_at.unwrap() { + if Utc::now() > expiry { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), @@ -229,7 +215,7 @@ pub async fn confirm_email( let update = sqlx::query!( "UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2", - email_pending_verification, + pending_email, user_id ) .execute(&state.db) @@ -287,13 +273,7 @@ pub async fn update_email( let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; let did = match auth_result { Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; let user = match sqlx::query!( @@ -319,18 +299,10 @@ pub async fn update_email( let email_pending_verification = user.email_pending_verification; let new_email = input.email.trim().to_lowercase(); - if new_email.is_empty() { + if !crate::api::validation::is_valid_email(&new_email) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "email is required"})), - ) - .into_response(); - } - - if !new_email.contains('@') || !new_email.contains('.') { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid email format"})), + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), ) .into_response(); } @@ -353,7 +325,17 @@ pub async fn update_email( } }; - let pending_email = email_pending_verification.unwrap(); + let pending_email = match email_pending_verification { + Some(p) => p, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + ) + .into_response(); + } + }; + if pending_email.to_lowercase() != new_email { return ( StatusCode::BAD_REQUEST, @@ -362,7 +344,18 @@ pub async fn update_email( .into_response(); } - if stored_code.unwrap() != confirmation_token { + let saved_code = match stored_code { + Some(c) => c, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})), + ) + .into_response(); + } + }; + + if saved_code != confirmation_token { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidToken", "message": "Invalid token"})), @@ -415,7 +408,7 @@ pub async fn update_email( match update { Ok(_) => { - info!("Email updated to {} for user {}", new_email, user_id); + info!("Email updated for user {}", user_id); (StatusCode::OK, Json(json!({}))).into_response() } Err(e) => { diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index e5f298a..f73d371 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -1,12 +1,13 @@ +use crate::api::ApiError; +use crate::auth::BearerAuth; use crate::state::AppState; +use crate::util::get_user_id_by_did; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; use uuid::Uuid; @@ -24,77 +25,28 @@ pub struct CreateInviteCodeOutput { pub async fn create_invite_code( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, Json(input): Json, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - if input.use_count < 1 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})), - ) - .into_response(); + return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); } - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; let creator_user_id = if let Some(for_account) = &input.for_account { - let target = sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) + match sqlx::query!("SELECT id FROM users WHERE did = $1", for_account) .fetch_optional(&state.db) - .await; - - match target { + .await + { Ok(Some(row)) => row.id, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Target account not found"})), - ) - .into_response(); - } + Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error looking up target account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } } } else { @@ -103,43 +55,38 @@ pub async fn create_invite_code( let user_invites_disabled = sqlx::query_scalar!( "SELECT invites_disabled FROM users WHERE did = $1", - did + auth_user.did ) .fetch_optional(&state.db) .await + .map_err(|e| { + error!("DB error checking invites_disabled: {:?}", e); + ApiError::InternalError + }) .ok() .flatten() .flatten() .unwrap_or(false); if user_invites_disabled { - return ( - StatusCode::FORBIDDEN, - Json(json!({"error": "InvitesDisabled", "message": "Invites are disabled for this account"})), - ) - .into_response(); + return ApiError::InvitesDisabled.into_response(); } let code = Uuid::new_v4().to_string(); - let result = sqlx::query!( + match sqlx::query!( "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", code, input.use_count, creator_user_id ) .execute(&state.db) - .await; - - match result { - Ok(_) => (StatusCode::OK, Json(CreateInviteCodeOutput { code })).into_response(), + .await + { + Ok(_) => Json(CreateInviteCodeOutput { code }).into_response(), Err(e) => { error!("DB error creating invite code: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError.into_response() } } } @@ -165,54 +112,16 @@ pub struct AccountCodes { pub async fn create_invite_codes( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, Json(input): Json, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - if input.use_count < 1 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "useCount must be at least 1"})), - ) - .into_response(); + return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); } - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; let code_count = input.code_count.unwrap_or(1).max(1); @@ -225,22 +134,17 @@ pub async fn create_invite_codes( for _ in 0..code_count { let code = Uuid::new_v4().to_string(); - let insert = sqlx::query!( + if let Err(e) = sqlx::query!( "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", code, input.use_count, user_id ) .execute(&state.db) - .await; - - if let Err(e) = insert { + .await + { error!("DB error creating invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } codes.push(code); @@ -252,22 +156,15 @@ pub async fn create_invite_codes( }); } else { for account_did in for_accounts { - let target = sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) + let target_user_id = match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did) .fetch_optional(&state.db) - .await; - - let target_user_id = match target { + .await + { Ok(Some(row)) => row.id, - Ok(None) => { - continue; - } + Ok(None) => continue, Err(e) => { error!("DB error looking up target account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } }; @@ -275,22 +172,17 @@ pub async fn create_invite_codes( for _ in 0..code_count { let code = Uuid::new_v4().to_string(); - let insert = sqlx::query!( + if let Err(e) = sqlx::query!( "INSERT INTO invite_codes (code, available_uses, created_by_user) VALUES ($1, $2, $3)", code, input.use_count, target_user_id ) .execute(&state.db) - .await; - - if let Err(e) = insert { + .await + { error!("DB error creating invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } codes.push(code); @@ -303,7 +195,7 @@ pub async fn create_invite_codes( } } - (StatusCode::OK, Json(CreateInviteCodesOutput { codes: result_codes })).into_response() + Json(CreateInviteCodesOutput { codes: result_codes }).into_response() } #[derive(Deserialize)] @@ -339,51 +231,17 @@ pub struct GetAccountInviteCodesOutput { pub async fn get_account_invite_codes( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, axum::extract::Query(params): axum::extract::Query, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await - { - Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { + Ok(id) => id, + Err(e) => return ApiError::from(e).into_response(), }; let include_used = params.include_used.unwrap_or(true); - let codes_result = sqlx::query!( + let codes_rows = match sqlx::query!( r#" SELECT code, available_uses, created_at, disabled FROM invite_codes @@ -393,9 +251,8 @@ pub async fn get_account_invite_codes( user_id ) .fetch_all(&state.db) - .await; - - let codes_rows = match codes_result { + .await + { Ok(rows) => { if include_used { rows @@ -405,17 +262,13 @@ pub async fn get_account_invite_codes( } Err(e) => { error!("DB error fetching invite codes: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } }; let mut codes = Vec::new(); for row in codes_rows { - let uses_result = sqlx::query!( + let uses = sqlx::query!( r#" SELECT u.did, icu.used_at FROM invite_code_uses icu @@ -426,29 +279,28 @@ pub async fn get_account_invite_codes( row.code ) .fetch_all(&state.db) - .await; - - let uses = match uses_result { - Ok(use_rows) => use_rows + .await + .map(|use_rows| { + use_rows .iter() .map(|u| InviteCodeUse { used_by: u.did.clone(), used_at: u.used_at.to_rfc3339(), }) - .collect(), - Err(_) => Vec::new(), - }; + .collect() + }) + .unwrap_or_default(); codes.push(InviteCode { code: row.code, available: row.available_uses, disabled: row.disabled.unwrap_or(false), - for_account: did.clone(), - created_by: did.clone(), + for_account: auth_user.did.clone(), + created_by: auth_user.did.clone(), created_at: row.created_at.to_rfc3339(), uses, }); } - (StatusCode::OK, Json(GetAccountInviteCodesOutput { codes })).into_response() + Json(GetAccountInviteCodesOutput { codes }).into_response() } diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 5117208..64cdb1d 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -4,6 +4,7 @@ pub mod email; pub mod invite; pub mod meta; pub mod password; +pub mod service_auth; pub mod session; pub mod signing_key; @@ -16,7 +17,6 @@ pub use email::{confirm_email, request_email_update, update_email}; pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; pub use meta::{describe_server, health}; pub use password::{request_password_reset, reset_password}; -pub use session::{ - create_session, delete_session, get_service_auth, get_session, refresh_session, -}; +pub use service_auth::get_service_auth; +pub use session::{create_session, delete_session, get_session, refresh_session}; pub use signing_key::reserve_signing_key; diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 52629f7..52339f2 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -7,17 +7,12 @@ use axum::{ }; use bcrypt::{hash, DEFAULT_COST}; use chrono::{Duration, Utc}; -use rand::Rng; use serde::Deserialize; use serde_json::json; use tracing::{error, info, warn}; fn generate_reset_code() -> String { - let mut rng = rand::thread_rng(); - let chars: Vec = "abcdefghijklmnopqrstuvwxyz234567".chars().collect(); - let part1: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - let part2: String = (0..5).map(|_| chars[rng.gen_range(0..chars.len())]).collect(); - format!("{}-{}", part1, part2) + crate::util::generate_token_code() } #[derive(Deserialize)] @@ -45,7 +40,7 @@ pub async fn request_password_reset( let user_id = match user { Ok(Some(row)) => row.id, Ok(None) => { - info!("Password reset requested for unknown email: {}", email); + info!("Password reset requested for unknown email"); return (StatusCode::OK, Json(json!({}))).into_response(); } Err(e) => { @@ -151,12 +146,15 @@ pub async fn reset_password( if let Some(exp) = expires_at { if Utc::now() > exp { - let _ = sqlx::query!( + if let Err(e) = sqlx::query!( "UPDATE users SET password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $1", user_id ) .execute(&state.db) - .await; + .await + { + error!("Failed to clear expired reset code: {:?}", e); + } return ( StatusCode::BAD_REQUEST, @@ -184,15 +182,26 @@ pub async fn reset_password( } }; - let update = sqlx::query!( + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + if let Err(e) = sqlx::query!( "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2", password_hash, user_id ) - .execute(&state.db) - .await; - - if let Err(e) = update { + .execute(&mut *tx) + .await + { error!("DB error updating password: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, @@ -201,9 +210,26 @@ pub async fn reset_password( .into_response(); } - let _ = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) - .execute(&state.db) - .await; + if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = (SELECT did FROM users WHERE id = $1)", user_id) + .execute(&mut *tx) + .await + { + error!("Failed to invalidate sessions after password reset: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit password reset transaction: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } info!("Password reset completed for user {}", user_id); diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs new file mode 100644 index 0000000..ec3bf51 --- /dev/null +++ b/src/api/server/service_auth.rs @@ -0,0 +1,63 @@ +use crate::api::ApiError; +use crate::state::AppState; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::error; + +#[derive(Deserialize)] +pub struct GetServiceAuthParams { + pub aud: String, + pub lxm: Option, + pub exp: Option, +} + +#[derive(Serialize)] +pub struct GetServiceAuthOutput { + pub token: String, +} + +pub async fn get_service_auth( + State(state): State, + headers: axum::http::HeaderMap, + Query(params): Query, +) -> Response { + let token = match crate::auth::extract_bearer_token_from_header( + headers.get("Authorization").and_then(|h| h.to_str().ok()) + ) { + Some(t) => t, + None => return ApiError::AuthenticationRequired.into_response(), + }; + + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { + Ok(user) => user, + Err(e) => return ApiError::from(e).into_response(), + }; + + let key_bytes = match auth_user.key_bytes { + Some(kb) => kb, + None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot create service auth".into()).into_response(), + }; + + let lxm = params.lxm.as_deref().unwrap_or("*"); + + let service_token = match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm, &key_bytes) + { + Ok(t) => t, + Err(e) => { + error!("Failed to create service token: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + + (StatusCode::OK, Json(GetServiceAuthOutput { token: service_token })).into_response() +} diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 8d65537..56f27c1 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -1,8 +1,9 @@ +use crate::api::ApiError; +use crate::auth::BearerAuth; use crate::state::AppState; use axum::{ Json, - extract::{Query, State}, - http::StatusCode, + extract::State, response::{IntoResponse, Response}, }; use bcrypt::verify; @@ -10,78 +11,6 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info, warn}; -#[derive(Deserialize)] -pub struct GetServiceAuthParams { - pub aud: String, - pub lxm: Option, - pub exp: Option, -} - -#[derive(Serialize)] -pub struct GetServiceAuthOutput { - pub token: String, -} - -pub async fn get_service_auth( - State(state): State, - headers: axum::http::HeaderMap, - Query(params): Query, -) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let (did, key_bytes) = match auth_result { - Ok(user) => { - let kb = match user.key_bytes { - Some(kb) => kb, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "OAuth tokens cannot create service auth"})), - ) - .into_response(); - } - }; - (user.did, kb) - } - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let lxm = params.lxm.as_deref().unwrap_or("*"); - - let service_token = match crate::auth::create_service_token(&did, ¶ms.aud, lxm, &key_bytes) - { - Ok(t) => t, - Err(e) => { - error!("Failed to create service token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - (StatusCode::OK, Json(GetServiceAuthOutput { token: service_token })).into_response() -} - #[derive(Deserialize)] pub struct CreateSessionInput { pub identifier: String, @@ -101,194 +30,104 @@ pub async fn create_session( State(state): State, Json(input): Json, ) -> Response { - info!("create_session: identifier='{}'", input.identifier); + info!("create_session called"); - let user_row = sqlx::query!( + let row = match sqlx::query!( "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1", input.identifier ) - .fetch_optional(&state.db) - .await; - - match user_row { - Ok(Some(row)) => { - let user_id = row.id; - let stored_hash = &row.password_hash; - let did = &row.did; - let handle = &row.handle; - let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let password_valid = if verify(&input.password, stored_hash).unwrap_or(false) { - true - } else { - let app_pass_rows = sqlx::query!("SELECT password_hash FROM app_passwords WHERE user_id = $1", user_id) - .fetch_all(&state.db) - .await - .unwrap_or_default(); - - app_pass_rows.iter().any(|row| { - verify(&input.password, &row.password_hash).unwrap_or(false) - }) - }; - - if password_valid { - let access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create access token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create refresh token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let session_insert = sqlx::query!( - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", - did, - access_meta.jti, - refresh_meta.jti, - access_meta.expires_at, - refresh_meta.expires_at - ) - .execute(&state.db) - .await; - - match session_insert { - Ok(_) => { - return ( - StatusCode::OK, - Json(CreateSessionOutput { - access_jwt: access_meta.token, - refresh_jwt: refresh_meta.token, - handle: handle.clone(), - did: did.clone(), - }), - ) - .into_response(); - } - Err(e) => { - error!("Failed to insert session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - } - } else { - warn!( - "Password verification failed for identifier: {}", - input.identifier - ); - } - } + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => row, Ok(None) => { - warn!("User not found for identifier: {}", input.identifier); + warn!("User not found for login attempt"); + return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); } Err(e) => { error!("Database error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); } + }; + + let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let password_valid = verify(&input.password, &row.password_hash).unwrap_or(false) + || sqlx::query!("SELECT password_hash FROM app_passwords WHERE user_id = $1", row.id) + .fetch_all(&state.db) + .await + .unwrap_or_default() + .iter() + .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false)); + + if !password_valid { + warn!("Password verification failed for login attempt"); + return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); } - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid identifier or password"})), + let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create access token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create refresh token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + if let Err(e) = sqlx::query!( + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + row.did, + access_meta.jti, + refresh_meta.jti, + access_meta.expires_at, + refresh_meta.expires_at ) - .into_response() + .execute(&state.db) + .await + { + error!("Failed to insert session: {:?}", e); + return ApiError::InternalError.into_response(); + } + + Json(CreateSessionOutput { + access_jwt: access_meta.token, + refresh_jwt: refresh_meta.token, + handle: row.handle, + did: row.did, + }).into_response() } pub async fn get_session( State(state): State, - headers: axum::http::HeaderMap, + BearerAuth(auth_user): BearerAuth, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()) - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Invalid Authorization header format"})), - ) - .into_response(); - } - }; - - let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await; - let did = match auth_result { - Ok(user) => user.did, - Err(e) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e})), - ) - .into_response(); - } - }; - - let user = sqlx::query!( - "SELECT handle, email FROM users WHERE did = $1", - did - ) - .fetch_optional(&state.db) - .await; - - match user { - Ok(Some(row)) => { - return ( - StatusCode::OK, - Json(json!({ - "handle": row.handle, - "did": did, - "email": row.email, - "didDoc": {} - })), - ) - .into_response(); - } - Ok(None) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } + match sqlx::query!("SELECT handle, email FROM users WHERE did = $1", auth_user.did) + .fetch_optional(&state.db) + .await + { + Ok(Some(row)) => Json(json!({ + "handle": row.handle, + "did": auth_user.did, + "email": row.email, + "didDoc": {} + })).into_response(), + Ok(None) => ApiError::AuthenticationFailed.into_response(), Err(e) => { error!("Database error in get_session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + ApiError::InternalError.into_response() } } } @@ -301,326 +140,174 @@ pub async fn delete_session( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + None => return ApiError::AuthenticationRequired.into_response(), }; - let jti = match crate::auth::get_did_from_token(&token) { - Ok(_) => { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - let claims_json = match URL_SAFE_NO_PAD.decode(parts[1]) { - Ok(bytes) => bytes, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; - let claims: serde_json::Value = match serde_json::from_slice(&claims_json) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; - match claims.get("jti").and_then(|j| j.as_str()) { - Some(jti) => jti.to_string(), - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - } - } - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } + let jti = match crate::auth::get_jti_from_token(&token) { + Ok(jti) => jti, + Err(_) => return ApiError::AuthenticationFailed.into_response(), }; - let result = sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) + match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) .execute(&state.db) - .await; - - match result { - Ok(res) => { - if res.rows_affected() > 0 { - return (StatusCode::OK, Json(json!({}))).into_response(); - } - } + .await + { + Ok(res) if res.rows_affected() > 0 => Json(json!({})).into_response(), + Ok(_) => ApiError::AuthenticationFailed.into_response(), Err(e) => { error!("Database error in delete_session: {:?}", e); + ApiError::AuthenticationFailed.into_response() } } - - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response() } pub async fn refresh_session( State(state): State, headers: axum::http::HeaderMap, ) -> Response { - use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; - let refresh_token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()) ) { Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); + None => return ApiError::AuthenticationRequired.into_response(), + }; + + let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) { + Ok(jti) => jti, + Err(_) => return ApiError::AuthenticationFailedMsg("Invalid token format".into()).into_response(), + }; + + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ApiError::InternalError.into_response(); } }; - let refresh_jti = { - let parts: Vec<&str> = refresh_token.split('.').collect(); - if parts.len() != 3 { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token format"})), - ) - .into_response(); - } - let claims_bytes = match URL_SAFE_NO_PAD.decode(parts[1]) { - Ok(b) => b, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; - let claims: serde_json::Value = match serde_json::from_slice(&claims_bytes) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - }; - match claims.get("jti").and_then(|j| j.as_str()) { - Some(jti) => jti.to_string(), - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } - } - }; - - let reuse_check = sqlx::query_scalar!( - "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + if let Ok(Some(session_id)) = sqlx::query_scalar!( + "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", refresh_jti ) - .fetch_optional(&state.db) - .await; - - if let Ok(Some(session_id)) = reuse_check { + .fetch_optional(&mut *tx) + .await + { warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id); let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) - .execute(&state.db) + .execute(&mut *tx) .await; - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "ExpiredToken", "message": "Refresh token has been revoked due to suspected compromise"})), - ) - .into_response(); + let _ = tx.commit().await; + return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response(); } - let session = sqlx::query!( + let session_row = match sqlx::query!( r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version FROM session_tokens st JOIN users u ON st.did = u.did JOIN user_keys k ON u.id = k.user_id - WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()"#, + WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW() + FOR UPDATE OF st"#, refresh_jti ) - .fetch_optional(&state.db) - .await; - - match session { - Ok(Some(session_row)) => { - let session_id = session_row.id; - let did = &session_row.did; - let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { - Ok(k) => k, - Err(e) => { - error!("Failed to decrypt user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - if let Err(_) = crate::auth::verify_refresh_token(&refresh_token, &key_bytes) { - return (StatusCode::UNAUTHORIZED, Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"}))).into_response(); - } - - let new_access_meta = match crate::auth::create_access_token_with_metadata(did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create access token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(did, &key_bytes) { - Ok(m) => m, - Err(e) => { - error!("Failed to create refresh token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - let mut tx = match state.db.begin().await { - Ok(tx) => tx, - Err(e) => { - error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - }; - - if let Err(e) = sqlx::query!( - "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2)", - refresh_jti, - session_id - ) - .execute(&mut *tx) - .await - { - error!("Failed to record used refresh token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - if let Err(e) = sqlx::query!( - "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", - new_access_meta.jti, - new_refresh_meta.jti, - new_access_meta.expires_at, - new_refresh_meta.expires_at, - session_id - ) - .execute(&mut *tx) - .await - { - error!("Database error updating session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - if let Err(e) = tx.commit().await { - error!("Failed to commit transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - - let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", did) - .fetch_optional(&state.db) - .await; - - match user { - Ok(Some(u)) => { - return ( - StatusCode::OK, - Json(json!({ - "accessJwt": new_access_meta.token, - "refreshJwt": new_refresh_meta.token, - "handle": u.handle, - "did": did - })), - ) - .into_response(); - } - Ok(None) => { - error!("User not found for existing session: {}", did); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - Err(e) => { - error!("Database error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - } - } - Ok(None) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid refresh token"})), - ) - .into_response(); - } + .fetch_optional(&mut *tx) + .await + { + Ok(Some(row)) => row, + Ok(None) => return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(), Err(e) => { error!("Database error fetching session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError.into_response(); + } + }; + + let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { + return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); + } + + let new_access_meta = match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create access token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) { + Ok(m) => m, + Err(e) => { + error!("Failed to create refresh token: {:?}", e); + return ApiError::InternalError.into_response(); + } + }; + + match sqlx::query!( + "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", + refresh_jti, + session_row.id + ) + .execute(&mut *tx) + .await + { + Ok(result) if result.rows_affected() == 0 => { + warn!("Concurrent refresh token reuse detected for session_id: {}", session_row.id); + let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_row.id) + .execute(&mut *tx) + .await; + let _ = tx.commit().await; + return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response(); + } + Err(e) => { + error!("Failed to record used refresh token: {:?}", e); + return ApiError::InternalError.into_response(); + } + Ok(_) => {} + } + + if let Err(e) = sqlx::query!( + "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", + new_access_meta.jti, + new_refresh_meta.jti, + new_access_meta.expires_at, + new_refresh_meta.expires_at, + session_row.id + ) + .execute(&mut *tx) + .await + { + error!("Database error updating session: {:?}", e); + return ApiError::InternalError.into_response(); + } + + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ApiError::InternalError.into_response(); + } + + match sqlx::query!("SELECT handle FROM users WHERE did = $1", session_row.did) + .fetch_optional(&state.db) + .await + { + Ok(Some(u)) => Json(json!({ + "accessJwt": new_access_meta.token, + "refreshJwt": new_refresh_meta.token, + "handle": u.handle, + "did": session_row.did + })).into_response(), + Ok(None) => { + error!("User not found for existing session: {}", session_row.did); + ApiError::InternalError.into_response() + } + Err(e) => { + error!("Database error fetching user: {:?}", e); + ApiError::InternalError.into_response() } } } diff --git a/src/api/validation.rs b/src/api/validation.rs new file mode 100644 index 0000000..f672110 --- /dev/null +++ b/src/api/validation.rs @@ -0,0 +1,104 @@ +pub const MAX_EMAIL_LENGTH: usize = 254; +pub const MAX_LOCAL_PART_LENGTH: usize = 64; +pub const MAX_DOMAIN_LENGTH: usize = 253; +pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; + +const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; + +pub fn is_valid_email(email: &str) -> bool { + let email = email.trim(); + + if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { + return false; + } + + let parts: Vec<&str> = email.rsplitn(2, '@').collect(); + if parts.len() != 2 { + return false; + } + + let domain = parts[0]; + let local = parts[1]; + + if local.is_empty() || local.len() > MAX_LOCAL_PART_LENGTH { + return false; + } + + if local.starts_with('.') || local.ends_with('.') { + return false; + } + + if local.contains("..") { + return false; + } + + for c in local.chars() { + if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { + return false; + } + } + + if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH { + return false; + } + + if !domain.contains('.') { + return false; + } + + for label in domain.split('.') { + if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { + return false; + } + + if label.starts_with('-') || label.ends_with('-') { + return false; + } + + for c in label.chars() { + if !c.is_ascii_alphanumeric() && c != '-' { + return false; + } + } + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_emails() { + assert!(is_valid_email("user@example.com")); + assert!(is_valid_email("user.name@example.com")); + assert!(is_valid_email("user+tag@example.com")); + assert!(is_valid_email("user@sub.example.com")); + assert!(is_valid_email("USER@EXAMPLE.COM")); + assert!(is_valid_email("user123@example123.com")); + assert!(is_valid_email("a@b.co")); + } + + #[test] + fn test_invalid_emails() { + assert!(!is_valid_email("")); + assert!(!is_valid_email("user")); + assert!(!is_valid_email("user@")); + assert!(!is_valid_email("@example.com")); + assert!(!is_valid_email("user@example")); + assert!(!is_valid_email("user@@example.com")); + assert!(!is_valid_email("user@.example.com")); + assert!(!is_valid_email("user@example..com")); + assert!(!is_valid_email(".user@example.com")); + assert!(!is_valid_email("user.@example.com")); + assert!(!is_valid_email("user..name@example.com")); + assert!(!is_valid_email("user@-example.com")); + assert!(!is_valid_email("user@example-.com")); + } + + #[test] + fn test_trimmed_whitespace() { + assert!(is_valid_email(" user@example.com ")); + } +} diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index c09e6d3..29d6e67 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -7,7 +7,7 @@ use axum::{ use serde_json::json; use crate::state::AppState; -use super::{AuthenticatedUser, validate_bearer_token}; +use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token, validate_bearer_token_allow_deactivated}; pub struct BearerAuth(pub AuthenticatedUser); @@ -112,8 +112,34 @@ impl FromRequestParts for BearerAuth { match validate_bearer_token(&state.db, token).await { Ok(user) => Ok(BearerAuth(user)), - Err("AccountDeactivated") => Err(AuthError::AccountDeactivated), - Err("AccountTakedown") => Err(AuthError::AccountTakedown), + Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), + Err(_) => Err(AuthError::AuthenticationFailed), + } + } +} + +pub struct BearerAuthAllowDeactivated(pub AuthenticatedUser); + +impl FromRequestParts for BearerAuthAllowDeactivated { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or(AuthError::MissingToken)? + .to_str() + .map_err(|_| AuthError::InvalidFormat)?; + + let token = extract_bearer_token(auth_header)?; + + match validate_bearer_token_allow_deactivated(&state.db, token).await { + Ok(user) => Ok(BearerAuthAllowDeactivated(user)), + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), Err(_) => Err(AuthError::AuthenticationFailed), } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 478e8da..039c4db 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,11 +1,12 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; +use std::fmt; pub mod extractor; pub mod token; pub mod verify; -pub use extractor::{BearerAuth, AuthError, extract_bearer_token_from_header}; +pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header}; pub use token::{ create_access_token, create_refresh_token, create_service_token, create_access_token_with_metadata, create_refresh_token_with_metadata, @@ -15,6 +16,25 @@ pub use token::{ }; pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TokenValidationError { + AccountDeactivated, + AccountTakedown, + KeyDecryptionFailed, + AuthenticationFailed, +} + +impl fmt::Display for TokenValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AccountDeactivated => write!(f, "AccountDeactivated"), + Self::AccountTakedown => write!(f, "AccountTakedown"), + Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"), + Self::AuthenticationFailed => write!(f, "AuthenticationFailed"), + } + } +} + pub struct AuthenticatedUser { pub did: String, pub key_bytes: Option>, @@ -24,14 +44,14 @@ pub struct AuthenticatedUser { pub async fn validate_bearer_token( db: &PgPool, token: &str, -) -> Result { +) -> Result { validate_bearer_token_with_options(db, token, false).await } pub async fn validate_bearer_token_allow_deactivated( db: &PgPool, token: &str, -) -> Result { +) -> Result { validate_bearer_token_with_options(db, token, true).await } @@ -39,7 +59,7 @@ async fn validate_bearer_token_with_options( db: &PgPool, token: &str, allow_deactivated: bool, -) -> Result { +) -> Result { let did_from_token = get_did_from_token(token).ok(); if let Some(ref did) = did_from_token { @@ -56,16 +76,14 @@ async fn validate_bearer_token_with_options( .flatten() { if !allow_deactivated && user.deactivated_at.is_some() { - return Err("AccountDeactivated"); + return Err(TokenValidationError::AccountDeactivated); } if user.takedown_ref.is_some() { - return Err("AccountTakedown"); + return Err(TokenValidationError::AccountTakedown); } - let decrypted_key = match crate::config::decrypt_key(&user.key_bytes, user.encryption_version) { - Ok(k) => k, - Err(_) => return Err("KeyDecryptionFailed"), - }; + let decrypted_key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version) + .map_err(|_| TokenValidationError::KeyDecryptionFailed)?; if let Ok(token_data) = verify_access_token(token, &decrypted_key) { let session_exists = sqlx::query_scalar!( @@ -103,10 +121,10 @@ async fn validate_bearer_token_with_options( .flatten() { if !allow_deactivated && oauth_token.deactivated_at.is_some() { - return Err("AccountDeactivated"); + return Err(TokenValidationError::AccountDeactivated); } if oauth_token.takedown_ref.is_some() { - return Err("AccountTakedown"); + return Err(TokenValidationError::AccountTakedown); } let now = chrono::Utc::now(); @@ -120,7 +138,7 @@ async fn validate_bearer_token_with_options( } } - Err("AuthenticationFailed") + Err(TokenValidationError::AuthenticationFailed) } #[derive(Debug, Serialize, Deserialize)] diff --git a/src/config.rs b/src/config.rs index 20eb632..fc1825f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -62,13 +62,17 @@ impl AuthConfig { let seed = hasher.finalize(); let signing_key = SigningKey::from_slice(&seed) - .expect("Failed to create signing key from seed"); + .unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e)); let verifying_key = signing_key.verifying_key(); let point = verifying_key.to_encoded_point(false); - let signing_key_x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); - let signing_key_y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + let signing_key_x = URL_SAFE_NO_PAD.encode( + point.x().expect("EC point missing X coordinate - this should never happen") + ); + let signing_key_y = URL_SAFE_NO_PAD.encode( + point.y().expect("EC point missing Y coordinate - this should never happen") + ); let mut kid_hasher = Sha256::new(); kid_hasher.update(signing_key_x.as_bytes()); diff --git a/src/lib.rs b/src/lib.rs index 789417c..b1ffdb7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod repo; pub mod state; pub mod storage; pub mod sync; +pub mod util; use axum::{ Router, diff --git a/src/main.rs b/src/main.rs index 3e8539b..44f1df5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,42 @@ use bspds::notifications::{EmailSender, NotificationService}; use bspds::state::AppState; use std::net::SocketAddr; +use std::process::ExitCode; use tokio::sync::watch; -use tracing::{info, warn}; +use tracing::{error, info, warn}; #[tokio::main] -async fn main() { +async fn main() -> ExitCode { dotenvy::dotenv().ok(); tracing_subscriber::fmt::init(); - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + error!("Fatal error: {}", e); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), Box> { + let database_url = std::env::var("DATABASE_URL") + .map_err(|_| "DATABASE_URL environment variable must be set")?; let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(5) + .max_connections(20) + .min_connections(2) + .acquire_timeout(std::time::Duration::from_secs(10)) + .idle_timeout(std::time::Duration::from_secs(300)) + .max_lifetime(std::time::Duration::from_secs(1800)) .connect(&database_url) .await - .expect("Failed to connect to Postgres"); + .map_err(|e| format!("Failed to connect to Postgres: {}", e))?; sqlx::migrate!("./migrations") .run(&pool) .await - .expect("Failed to run migrations"); + .map_err(|e| format!("Failed to run migrations: {}", e))?; let state = AppState::new(pool.clone()).await; @@ -50,7 +66,9 @@ async fn main() { let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); info!("listening on {}", addr); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; let server_result = axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal(shutdown_tx)) @@ -59,23 +77,33 @@ async fn main() { notification_handle.await.ok(); if let Err(e) = server_result { - tracing::error!("Server error: {}", e); + return Err(format!("Server error: {}", e).into()); } + + Ok(()) } async fn shutdown_signal(shutdown_tx: watch::Sender) { let ctrl_c = async { - tokio::signal::ctrl_c() - .await - .expect("Failed to install Ctrl+C handler"); + match tokio::signal::ctrl_c().await { + Ok(()) => {} + Err(e) => { + error!("Failed to install Ctrl+C handler: {}", e); + } + } }; #[cfg(unix)] let terminate = async { - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("Failed to install signal handler") - .recv() - .await; + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(e) => { + error!("Failed to install SIGTERM handler: {}", e); + std::future::pending::<()>().await; + } + } }; #[cfg(not(unix))] diff --git a/src/oauth/db.rs b/src/oauth/db.rs deleted file mode 100644 index b412e62..0000000 --- a/src/oauth/db.rs +++ /dev/null @@ -1,641 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{de::DeserializeOwned, Serialize}; -use sqlx::PgPool; - -use super::{ - AuthorizationRequestParameters, ClientAuth, DeviceData, OAuthError, RequestData, TokenData, - AuthorizedClientData, -}; - -fn to_json(value: &T) -> Result { - serde_json::to_value(value).map_err(|e| { - tracing::error!("JSON serialization error: {}", e); - OAuthError::ServerError("Internal serialization error".to_string()) - }) -} - -fn from_json(value: serde_json::Value) -> Result { - serde_json::from_value(value).map_err(|e| { - tracing::error!("JSON deserialization error: {}", e); - OAuthError::ServerError("Internal data corruption".to_string()) - }) -} - -pub async fn create_device( - pool: &PgPool, - device_id: &str, - data: &DeviceData, -) -> Result<(), OAuthError> { - sqlx::query!( - r#" - INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at) - VALUES ($1, $2, $3, $4, $5) - "#, - device_id, - data.session_id, - data.user_agent, - data.ip_address, - data.last_seen_at, - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn get_device(pool: &PgPool, device_id: &str) -> Result, OAuthError> { - let row = sqlx::query!( - r#" - SELECT session_id, user_agent, ip_address, last_seen_at - FROM oauth_device - WHERE id = $1 - "#, - device_id - ) - .fetch_optional(pool) - .await?; - - Ok(row.map(|r| DeviceData { - session_id: r.session_id, - user_agent: r.user_agent, - ip_address: r.ip_address, - last_seen_at: r.last_seen_at, - })) -} - -pub async fn update_device_last_seen( - pool: &PgPool, - device_id: &str, -) -> Result<(), OAuthError> { - sqlx::query!( - r#" - UPDATE oauth_device - SET last_seen_at = NOW() - WHERE id = $1 - "#, - device_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> { - sqlx::query!( - r#" - DELETE FROM oauth_device WHERE id = $1 - "#, - device_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn create_authorization_request( - pool: &PgPool, - request_id: &str, - data: &RequestData, -) -> Result<(), OAuthError> { - let client_auth_json = match &data.client_auth { - Some(ca) => Some(to_json(ca)?), - None => None, - }; - let parameters_json = to_json(&data.parameters)?; - - sqlx::query!( - r#" - INSERT INTO oauth_authorization_request - (id, did, device_id, client_id, client_auth, parameters, expires_at, code) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - "#, - request_id, - data.did, - data.device_id, - data.client_id, - client_auth_json, - parameters_json, - data.expires_at, - data.code, - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn get_authorization_request( - pool: &PgPool, - request_id: &str, -) -> Result, OAuthError> { - let row = sqlx::query!( - r#" - SELECT did, device_id, client_id, client_auth, parameters, expires_at, code - FROM oauth_authorization_request - WHERE id = $1 - "#, - request_id - ) - .fetch_optional(pool) - .await?; - - match row { - Some(r) => { - let client_auth: Option = match r.client_auth { - Some(v) => Some(from_json(v)?), - None => None, - }; - let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; - - Ok(Some(RequestData { - client_id: r.client_id, - client_auth, - parameters, - expires_at: r.expires_at, - did: r.did, - device_id: r.device_id, - code: r.code, - })) - } - None => Ok(None), - } -} - -pub async fn update_authorization_request( - pool: &PgPool, - request_id: &str, - did: &str, - device_id: Option<&str>, - code: &str, -) -> Result<(), OAuthError> { - sqlx::query!( - r#" - UPDATE oauth_authorization_request - SET did = $2, device_id = $3, code = $4 - WHERE id = $1 - "#, - request_id, - did, - device_id, - code - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn consume_authorization_request_by_code( - pool: &PgPool, - code: &str, -) -> Result, OAuthError> { - let row = sqlx::query!( - r#" - DELETE FROM oauth_authorization_request - WHERE code = $1 - RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code - "#, - code - ) - .fetch_optional(pool) - .await?; - - match row { - Some(r) => { - let client_auth: Option = match r.client_auth { - Some(v) => Some(from_json(v)?), - None => None, - }; - let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; - - Ok(Some(RequestData { - client_id: r.client_id, - client_auth, - parameters, - expires_at: r.expires_at, - did: r.did, - device_id: r.device_id, - code: r.code, - })) - } - None => Ok(None), - } -} - -pub async fn delete_authorization_request( - pool: &PgPool, - request_id: &str, -) -> Result<(), OAuthError> { - sqlx::query!( - r#" - DELETE FROM oauth_authorization_request WHERE id = $1 - "#, - request_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result { - let result = sqlx::query!( - r#" - DELETE FROM oauth_authorization_request - WHERE expires_at < NOW() - "# - ) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -pub async fn create_token( - pool: &PgPool, - data: &TokenData, -) -> Result { - let client_auth_json = to_json(&data.client_auth)?; - let parameters_json = to_json(&data.parameters)?; - - let row = sqlx::query!( - r#" - INSERT INTO oauth_token - (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, - device_id, parameters, details, code, current_refresh_token, scope) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING id - "#, - data.did, - data.token_id, - data.created_at, - data.updated_at, - data.expires_at, - data.client_id, - client_auth_json, - data.device_id, - parameters_json, - data.details, - data.code, - data.current_refresh_token, - data.scope, - ) - .fetch_one(pool) - .await?; - - Ok(row.id) -} - -pub async fn get_token_by_id( - pool: &PgPool, - token_id: &str, -) -> Result, OAuthError> { - let row = sqlx::query!( - r#" - SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, - device_id, parameters, details, code, current_refresh_token, scope - FROM oauth_token - WHERE token_id = $1 - "#, - token_id - ) - .fetch_optional(pool) - .await?; - - match row { - Some(r) => Ok(Some(TokenData { - did: r.did, - token_id: r.token_id, - created_at: r.created_at, - updated_at: r.updated_at, - expires_at: r.expires_at, - client_id: r.client_id, - client_auth: from_json(r.client_auth)?, - device_id: r.device_id, - parameters: from_json(r.parameters)?, - details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, - scope: r.scope, - })), - None => Ok(None), - } -} - -pub async fn get_token_by_refresh_token( - pool: &PgPool, - refresh_token: &str, -) -> Result, OAuthError> { - let row = sqlx::query!( - r#" - SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, - device_id, parameters, details, code, current_refresh_token, scope - FROM oauth_token - WHERE current_refresh_token = $1 - "#, - refresh_token - ) - .fetch_optional(pool) - .await?; - - match row { - Some(r) => Ok(Some(( - r.id, - TokenData { - did: r.did, - token_id: r.token_id, - created_at: r.created_at, - updated_at: r.updated_at, - expires_at: r.expires_at, - client_id: r.client_id, - client_auth: from_json(r.client_auth)?, - device_id: r.device_id, - parameters: from_json(r.parameters)?, - details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, - scope: r.scope, - }, - ))), - None => Ok(None), - } -} - -pub async fn rotate_token( - pool: &PgPool, - old_db_id: i32, - new_token_id: &str, - new_refresh_token: &str, - new_expires_at: DateTime, -) -> Result<(), OAuthError> { - let mut tx = pool.begin().await?; - - let old_refresh = sqlx::query_scalar!( - r#" - SELECT current_refresh_token FROM oauth_token WHERE id = $1 - "#, - old_db_id - ) - .fetch_one(&mut *tx) - .await?; - - if let Some(old_rt) = old_refresh { - sqlx::query!( - r#" - INSERT INTO oauth_used_refresh_token (refresh_token, token_id) - VALUES ($1, $2) - "#, - old_rt, - old_db_id - ) - .execute(&mut *tx) - .await?; - } - - sqlx::query!( - r#" - UPDATE oauth_token - SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() - WHERE id = $1 - "#, - old_db_id, - new_token_id, - new_refresh_token, - new_expires_at - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - Ok(()) -} - -pub async fn check_refresh_token_used( - pool: &PgPool, - refresh_token: &str, -) -> Result, OAuthError> { - let row = sqlx::query_scalar!( - r#" - SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 - "#, - refresh_token - ) - .fetch_optional(pool) - .await?; - - Ok(row) -} - -pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> { - sqlx::query!( - r#" - DELETE FROM oauth_token WHERE token_id = $1 - "#, - token_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> { - sqlx::query!( - r#" - DELETE FROM oauth_token WHERE id = $1 - "#, - db_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn upsert_account_device( - pool: &PgPool, - did: &str, - device_id: &str, -) -> Result<(), OAuthError> { - sqlx::query!( - r#" - INSERT INTO oauth_account_device (did, device_id, created_at, updated_at) - VALUES ($1, $2, NOW(), NOW()) - ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW() - "#, - did, - device_id - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn upsert_authorized_client( - pool: &PgPool, - did: &str, - client_id: &str, - data: &AuthorizedClientData, -) -> Result<(), OAuthError> { - let data_json = to_json(data)?; - - sqlx::query!( - r#" - INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data) - VALUES ($1, $2, NOW(), NOW(), $3) - ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3 - "#, - did, - client_id, - data_json - ) - .execute(pool) - .await?; - - Ok(()) -} - -pub async fn get_authorized_client( - pool: &PgPool, - did: &str, - client_id: &str, -) -> Result, OAuthError> { - let row = sqlx::query_scalar!( - r#" - SELECT data FROM oauth_authorized_client - WHERE did = $1 AND client_id = $2 - "#, - did, - client_id - ) - .fetch_optional(pool) - .await?; - - match row { - Some(v) => Ok(Some(from_json(v)?)), - None => Ok(None), - } -} - -pub async fn list_tokens_for_user( - pool: &PgPool, - did: &str, -) -> Result, OAuthError> { - let rows = sqlx::query!( - r#" - SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, - device_id, parameters, details, code, current_refresh_token, scope - FROM oauth_token - WHERE did = $1 - "#, - did - ) - .fetch_all(pool) - .await?; - - let mut tokens = Vec::with_capacity(rows.len()); - for r in rows { - tokens.push(TokenData { - did: r.did, - token_id: r.token_id, - created_at: r.created_at, - updated_at: r.updated_at, - expires_at: r.expires_at, - client_id: r.client_id, - client_auth: from_json(r.client_auth)?, - device_id: r.device_id, - parameters: from_json(r.parameters)?, - details: r.details, - code: r.code, - current_refresh_token: r.current_refresh_token, - scope: r.scope, - }); - } - Ok(tokens) -} - -pub async fn check_and_record_dpop_jti( - pool: &PgPool, - jti: &str, -) -> Result { - let result = sqlx::query!( - r#" - INSERT INTO oauth_dpop_jti (jti) - VALUES ($1) - ON CONFLICT (jti) DO NOTHING - "#, - jti - ) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -pub async fn cleanup_expired_dpop_jtis( - pool: &PgPool, - max_age_secs: i64, -) -> Result { - let result = sqlx::query!( - r#" - DELETE FROM oauth_dpop_jti - WHERE created_at < NOW() - INTERVAL '1 second' * $1 - "#, - max_age_secs as f64 - ) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result { - let count = sqlx::query_scalar!( - r#" - SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1 - "#, - did - ) - .fetch_one(pool) - .await?; - - Ok(count) -} - -pub async fn delete_oldest_tokens_for_user( - pool: &PgPool, - did: &str, - keep_count: i64, -) -> Result { - let result = sqlx::query!( - r#" - DELETE FROM oauth_token - WHERE id IN ( - SELECT id FROM oauth_token - WHERE did = $1 - ORDER BY updated_at ASC - OFFSET $2 - ) - "#, - did, - keep_count - ) - .execute(pool) - .await?; - - Ok(result.rows_affected()) -} - -const MAX_TOKENS_PER_USER: i64 = 100; - -pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> { - let count = count_tokens_for_user(pool, did).await?; - if count > MAX_TOKENS_PER_USER { - let to_keep = MAX_TOKENS_PER_USER - 1; - delete_oldest_tokens_for_user(pool, did, to_keep).await?; - } - Ok(()) -} diff --git a/src/oauth/db/client.rs b/src/oauth/db/client.rs new file mode 100644 index 0000000..a0db87f --- /dev/null +++ b/src/oauth/db/client.rs @@ -0,0 +1,50 @@ +use sqlx::PgPool; + +use super::super::{AuthorizedClientData, OAuthError}; +use super::helpers::{from_json, to_json}; + +pub async fn upsert_authorized_client( + pool: &PgPool, + did: &str, + client_id: &str, + data: &AuthorizedClientData, +) -> Result<(), OAuthError> { + let data_json = to_json(data)?; + + sqlx::query!( + r#" + INSERT INTO oauth_authorized_client (did, client_id, created_at, updated_at, data) + VALUES ($1, $2, NOW(), NOW(), $3) + ON CONFLICT (did, client_id) DO UPDATE SET updated_at = NOW(), data = $3 + "#, + did, + client_id, + data_json + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_authorized_client( + pool: &PgPool, + did: &str, + client_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query_scalar!( + r#" + SELECT data FROM oauth_authorized_client + WHERE did = $1 AND client_id = $2 + "#, + did, + client_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(v) => Ok(Some(from_json(v)?)), + None => Ok(None), + } +} diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs new file mode 100644 index 0000000..cf5633e --- /dev/null +++ b/src/oauth/db/device.rs @@ -0,0 +1,96 @@ +use sqlx::PgPool; + +use super::super::{DeviceData, OAuthError}; + +pub async fn create_device( + pool: &PgPool, + device_id: &str, + data: &DeviceData, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + INSERT INTO oauth_device (id, session_id, user_agent, ip_address, last_seen_at) + VALUES ($1, $2, $3, $4, $5) + "#, + device_id, + data.session_id, + data.user_agent, + data.ip_address, + data.last_seen_at, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_device(pool: &PgPool, device_id: &str) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT session_id, user_agent, ip_address, last_seen_at + FROM oauth_device + WHERE id = $1 + "#, + device_id + ) + .fetch_optional(pool) + .await?; + + Ok(row.map(|r| DeviceData { + session_id: r.session_id, + user_agent: r.user_agent, + ip_address: r.ip_address, + last_seen_at: r.last_seen_at, + })) +} + +pub async fn update_device_last_seen( + pool: &PgPool, + device_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_device + SET last_seen_at = NOW() + WHERE id = $1 + "#, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_device(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_device WHERE id = $1 + "#, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn upsert_account_device( + pool: &PgPool, + did: &str, + device_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + INSERT INTO oauth_account_device (did, device_id, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) + ON CONFLICT (did, device_id) DO UPDATE SET updated_at = NOW() + "#, + did, + device_id + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/oauth/db/dpop.rs b/src/oauth/db/dpop.rs new file mode 100644 index 0000000..01a2935 --- /dev/null +++ b/src/oauth/db/dpop.rs @@ -0,0 +1,38 @@ +use sqlx::PgPool; + +use super::super::OAuthError; + +pub async fn check_and_record_dpop_jti( + pool: &PgPool, + jti: &str, +) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO oauth_dpop_jti (jti) + VALUES ($1) + ON CONFLICT (jti) DO NOTHING + "#, + jti + ) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +pub async fn cleanup_expired_dpop_jtis( + pool: &PgPool, + max_age_secs: i64, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_dpop_jti + WHERE created_at < NOW() - INTERVAL '1 second' * $1 + "#, + max_age_secs as f64 + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src/oauth/db/helpers.rs b/src/oauth/db/helpers.rs new file mode 100644 index 0000000..a13bda3 --- /dev/null +++ b/src/oauth/db/helpers.rs @@ -0,0 +1,17 @@ +use serde::{de::DeserializeOwned, Serialize}; + +use super::super::OAuthError; + +pub fn to_json(value: &T) -> Result { + serde_json::to_value(value).map_err(|e| { + tracing::error!("JSON serialization error: {}", e); + OAuthError::ServerError("Internal serialization error".to_string()) + }) +} + +pub fn from_json(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| { + tracing::error!("JSON deserialization error: {}", e); + OAuthError::ServerError("Internal data corruption".to_string()) + }) +} diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs new file mode 100644 index 0000000..c4c157f --- /dev/null +++ b/src/oauth/db/mod.rs @@ -0,0 +1,22 @@ +mod client; +mod device; +mod dpop; +mod helpers; +mod request; +mod token; + +pub use client::{get_authorized_client, upsert_authorized_client}; +pub use device::{ + create_device, delete_device, get_device, update_device_last_seen, upsert_account_device, +}; +pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis}; +pub use request::{ + consume_authorization_request_by_code, create_authorization_request, + delete_authorization_request, delete_expired_authorization_requests, get_authorization_request, + update_authorization_request, +}; +pub use token::{ + check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, + delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, + get_token_by_refresh_token, list_tokens_for_user, rotate_token, +}; diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs new file mode 100644 index 0000000..6636a04 --- /dev/null +++ b/src/oauth/db/request.rs @@ -0,0 +1,163 @@ +use sqlx::PgPool; + +use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData}; +use super::helpers::{from_json, to_json}; + +pub async fn create_authorization_request( + pool: &PgPool, + request_id: &str, + data: &RequestData, +) -> Result<(), OAuthError> { + let client_auth_json = match &data.client_auth { + Some(ca) => Some(to_json(ca)?), + None => None, + }; + let parameters_json = to_json(&data.parameters)?; + + sqlx::query!( + r#" + INSERT INTO oauth_authorization_request + (id, did, device_id, client_id, client_auth, parameters, expires_at, code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + request_id, + data.did, + data.device_id, + data.client_id, + client_auth_json, + parameters_json, + data.expires_at, + data.code, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_authorization_request( + pool: &PgPool, + request_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT did, device_id, client_id, client_auth, parameters, expires_at, code + FROM oauth_authorization_request + WHERE id = $1 + "#, + request_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + })) + } + None => Ok(None), + } +} + +pub async fn update_authorization_request( + pool: &PgPool, + request_id: &str, + did: &str, + device_id: Option<&str>, + code: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + UPDATE oauth_authorization_request + SET did = $2, device_id = $3, code = $4 + WHERE id = $1 + "#, + request_id, + did, + device_id, + code + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn consume_authorization_request_by_code( + pool: &PgPool, + code: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE code = $1 + RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code + "#, + code + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => { + let client_auth: Option = match r.client_auth { + Some(v) => Some(from_json(v)?), + None => None, + }; + let parameters: AuthorizationRequestParameters = from_json(r.parameters)?; + + Ok(Some(RequestData { + client_id: r.client_id, + client_auth, + parameters, + expires_at: r.expires_at, + did: r.did, + device_id: r.device_id, + code: r.code, + })) + } + None => Ok(None), + } +} + +pub async fn delete_authorization_request( + pool: &PgPool, + request_id: &str, +) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_authorization_request WHERE id = $1 + "#, + request_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_expired_authorization_requests(pool: &PgPool) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_authorization_request + WHERE expires_at < NOW() + "# + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs new file mode 100644 index 0000000..8faaaac --- /dev/null +++ b/src/oauth/db/token.rs @@ -0,0 +1,291 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +use super::super::{OAuthError, TokenData}; +use super::helpers::{from_json, to_json}; + +pub async fn create_token( + pool: &PgPool, + data: &TokenData, +) -> Result { + let client_auth_json = to_json(&data.client_auth)?; + let parameters_json = to_json(&data.parameters)?; + + let row = sqlx::query!( + r#" + INSERT INTO oauth_token + (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id + "#, + data.did, + data.token_id, + data.created_at, + data.updated_at, + data.expires_at, + data.client_id, + client_auth_json, + data.device_id, + parameters_json, + data.details, + data.code, + data.current_refresh_token, + data.scope, + ) + .fetch_one(pool) + .await?; + + Ok(row.id) +} + +pub async fn get_token_by_id( + pool: &PgPool, + token_id: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE token_id = $1 + "#, + token_id + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => Ok(Some(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + })), + None => Ok(None), + } +} + +pub async fn get_token_by_refresh_token( + pool: &PgPool, + refresh_token: &str, +) -> Result, OAuthError> { + let row = sqlx::query!( + r#" + SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE current_refresh_token = $1 + "#, + refresh_token + ) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => Ok(Some(( + r.id, + TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + }, + ))), + None => Ok(None), + } +} + +pub async fn rotate_token( + pool: &PgPool, + old_db_id: i32, + new_token_id: &str, + new_refresh_token: &str, + new_expires_at: DateTime, +) -> Result<(), OAuthError> { + let mut tx = pool.begin().await?; + + let old_refresh = sqlx::query_scalar!( + r#" + SELECT current_refresh_token FROM oauth_token WHERE id = $1 + "#, + old_db_id + ) + .fetch_one(&mut *tx) + .await?; + + if let Some(old_rt) = old_refresh { + sqlx::query!( + r#" + INSERT INTO oauth_used_refresh_token (refresh_token, token_id) + VALUES ($1, $2) + "#, + old_rt, + old_db_id + ) + .execute(&mut *tx) + .await?; + } + + sqlx::query!( + r#" + UPDATE oauth_token + SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW() + WHERE id = $1 + "#, + old_db_id, + new_token_id, + new_refresh_token, + new_expires_at + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +pub async fn check_refresh_token_used( + pool: &PgPool, + refresh_token: &str, +) -> Result, OAuthError> { + let row = sqlx::query_scalar!( + r#" + SELECT token_id FROM oauth_used_refresh_token WHERE refresh_token = $1 + "#, + refresh_token + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE token_id = $1 + "#, + token_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthError> { + sqlx::query!( + r#" + DELETE FROM oauth_token WHERE id = $1 + "#, + db_id + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_tokens_for_user( + pool: &PgPool, + did: &str, +) -> Result, OAuthError> { + let rows = sqlx::query!( + r#" + SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, + device_id, parameters, details, code, current_refresh_token, scope + FROM oauth_token + WHERE did = $1 + "#, + did + ) + .fetch_all(pool) + .await?; + + let mut tokens = Vec::with_capacity(rows.len()); + for r in rows { + tokens.push(TokenData { + did: r.did, + token_id: r.token_id, + created_at: r.created_at, + updated_at: r.updated_at, + expires_at: r.expires_at, + client_id: r.client_id, + client_auth: from_json(r.client_auth)?, + device_id: r.device_id, + parameters: from_json(r.parameters)?, + details: r.details, + code: r.code, + current_refresh_token: r.current_refresh_token, + scope: r.scope, + }); + } + Ok(tokens) +} + +pub async fn count_tokens_for_user(pool: &PgPool, did: &str) -> Result { + let count = sqlx::query_scalar!( + r#" + SELECT COUNT(*) as "count!" FROM oauth_token WHERE did = $1 + "#, + did + ) + .fetch_one(pool) + .await?; + + Ok(count) +} + +pub async fn delete_oldest_tokens_for_user( + pool: &PgPool, + did: &str, + keep_count: i64, +) -> Result { + let result = sqlx::query!( + r#" + DELETE FROM oauth_token + WHERE id IN ( + SELECT id FROM oauth_token + WHERE did = $1 + ORDER BY updated_at ASC + OFFSET $2 + ) + "#, + did, + keep_count + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + +const MAX_TOKENS_PER_USER: i64 = 100; + +pub async fn enforce_token_limit_for_user(pool: &PgPool, did: &str) -> Result<(), OAuthError> { + let count = count_tokens_for_user(pool, did).await?; + if count > MAX_TOKENS_PER_USER { + let to_keep = MAX_TOKENS_PER_USER - 1; + delete_oldest_tokens_for_user(pool, did, to_keep).await?; + } + Ok(()) +} diff --git a/src/oauth/dpop.rs b/src/oauth/dpop.rs index 08641e6..bff92e1 100644 --- a/src/oauth/dpop.rs +++ b/src/oauth/dpop.rs @@ -237,12 +237,11 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O false, ); - let affine = AffinePoint::from_encoded_point(&point); - if affine.is_none().into() { - return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); - } + let affine_opt: Option = AffinePoint::from_encoded_point(&point).into(); + let affine = affine_opt + .ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; - let verifying_key = VerifyingKey::from_affine(affine.unwrap()) + let verifying_key = VerifyingKey::from_affine(affine) .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; let sig = Signature::from_slice(signature) @@ -287,12 +286,11 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O false, ); - let affine = AffinePoint::from_encoded_point(&point); - if affine.is_none().into() { - return Err(OAuthError::InvalidDpopProof("Invalid EC point".to_string())); - } + let affine_opt: Option = AffinePoint::from_encoded_point(&point).into(); + let affine = affine_opt + .ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?; - let verifying_key = VerifyingKey::from_affine(affine.unwrap()) + let verifying_key = VerifyingKey::from_affine(affine) .map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?; let sig = Signature::from_slice(signature) diff --git a/src/oauth/endpoints/token.rs b/src/oauth/endpoints/token.rs deleted file mode 100644 index 6201f87..0000000 --- a/src/oauth/endpoints/token.rs +++ /dev/null @@ -1,558 +0,0 @@ -use axum::{ - Form, Json, - extract::State, - http::{HeaderMap, StatusCode}, -}; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use chrono::{Duration, Utc}; -use hmac::Mac; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; - -use crate::config::AuthConfig; -use crate::state::AppState; -use crate::oauth::{ - ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, - client::{ClientMetadataCache, verify_client_auth}, - db, - dpop::DPoPVerifier, -}; - -const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; -const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; - -#[derive(Debug, Deserialize)] -pub struct TokenRequest { - pub grant_type: String, - #[serde(default)] - pub code: Option, - #[serde(default)] - pub redirect_uri: Option, - #[serde(default)] - pub code_verifier: Option, - #[serde(default)] - pub refresh_token: Option, - #[serde(default)] - pub client_id: Option, - #[serde(default)] - pub client_secret: Option, - #[serde(default)] - pub client_assertion: Option, - #[serde(default)] - pub client_assertion_type: Option, -} - -#[derive(Debug, Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub token_type: String, - pub expires_in: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub: Option, -} - -pub async fn token_endpoint( - State(state): State, - headers: HeaderMap, - Form(request): Form, -) -> Result<(HeaderMap, Json), OAuthError> { - let dpop_proof = headers - .get("DPoP") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - match request.grant_type.as_str() { - "authorization_code" => { - handle_authorization_code_grant(state, headers, request, dpop_proof).await - } - "refresh_token" => { - handle_refresh_token_grant(state, headers, request, dpop_proof).await - } - _ => Err(OAuthError::UnsupportedGrantType(format!( - "Unsupported grant_type: {}", - request.grant_type - ))), - } -} - -async fn handle_authorization_code_grant( - state: AppState, - _headers: HeaderMap, - request: TokenRequest, - dpop_proof: Option, -) -> Result<(HeaderMap, Json), OAuthError> { - let code = request - .code - .ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?; - - let code_verifier = request - .code_verifier - .ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?; - - let auth_request = db::consume_authorization_request_by_code(&state.db, &code) - .await? - .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; - - if auth_request.expires_at < Utc::now() { - return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string())); - } - - if let Some(request_client_id) = &request.client_id { - if request_client_id != &auth_request.client_id { - return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); - } - } - - let did = auth_request - .did - .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; - - let client_metadata_cache = ClientMetadataCache::new(3600); - let client_metadata = client_metadata_cache - .get(&auth_request.client_id) - .await?; - let client_auth = auth_request.client_auth.clone().unwrap_or(ClientAuth::None); - verify_client_auth(&client_metadata, &client_auth)?; - - verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; - - if let Some(redirect_uri) = &request.redirect_uri { - if redirect_uri != &auth_request.parameters.redirect_uri { - return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string())); - } - } - - let dpop_jkt = if let Some(proof) = &dpop_proof { - let config = AuthConfig::get(); - let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let token_endpoint = format!("https://{}/oauth/token", pds_hostname); - - let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; - - if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { - return Err(OAuthError::InvalidDpopProof( - "DPoP proof has already been used".to_string(), - )); - } - - if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt { - if &result.jkt != expected_jkt { - return Err(OAuthError::InvalidDpopProof( - "DPoP key binding mismatch".to_string(), - )); - } - } - - Some(result.jkt) - } else if auth_request.parameters.dpop_jkt.is_some() { - return Err(OAuthError::InvalidRequest( - "DPoP proof required for this authorization".to_string(), - )); - } else { - None - }; - - let token_id = TokenId::generate(); - let refresh_token = RefreshToken::generate(); - let now = Utc::now(); - - let access_token = create_access_token(&token_id.0, &did, dpop_jkt.as_deref())?; - - let token_data = TokenData { - did: did.clone(), - token_id: token_id.0.clone(), - created_at: now, - updated_at: now, - expires_at: now + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS), - client_id: auth_request.client_id.clone(), - client_auth: auth_request.client_auth.unwrap_or(ClientAuth::None), - device_id: auth_request.device_id, - parameters: auth_request.parameters.clone(), - details: None, - code: None, - current_refresh_token: Some(refresh_token.0.clone()), - scope: auth_request.parameters.scope.clone(), - }; - - db::create_token(&state.db, &token_data).await?; - - tokio::spawn({ - let pool = state.db.clone(); - let did_clone = did.clone(); - async move { - if let Err(e) = db::enforce_token_limit_for_user(&pool, &did_clone).await { - tracing::warn!("Failed to enforce token limit for user: {:?}", e); - } - } - }); - - let mut response_headers = HeaderMap::new(); - let config = AuthConfig::get(); - let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert( - "DPoP-Nonce", - verifier.generate_nonce().parse().unwrap(), - ); - - Ok(( - response_headers, - Json(TokenResponse { - access_token, - token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, - refresh_token: Some(refresh_token.0), - scope: auth_request.parameters.scope, - sub: Some(did), - }), - )) -} - -async fn handle_refresh_token_grant( - state: AppState, - _headers: HeaderMap, - request: TokenRequest, - dpop_proof: Option, -) -> Result<(HeaderMap, Json), OAuthError> { - let refresh_token_str = request - .refresh_token - .ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?; - - if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { - db::delete_token_family(&state.db, token_id).await?; - return Err(OAuthError::InvalidGrant( - "Refresh token reuse detected, token family revoked".to_string(), - )); - } - - let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str) - .await? - .ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?; - - if token_data.expires_at < Utc::now() { - db::delete_token_family(&state.db, db_id).await?; - return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string())); - } - - let dpop_jkt = if let Some(proof) = &dpop_proof { - let config = AuthConfig::get(); - let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let token_endpoint = format!("https://{}/oauth/token", pds_hostname); - - let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; - - if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { - return Err(OAuthError::InvalidDpopProof( - "DPoP proof has already been used".to_string(), - )); - } - - if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { - if &result.jkt != expected_jkt { - return Err(OAuthError::InvalidDpopProof( - "DPoP key binding mismatch".to_string(), - )); - } - } - - Some(result.jkt) - } else if token_data.parameters.dpop_jkt.is_some() { - return Err(OAuthError::InvalidRequest( - "DPoP proof required".to_string(), - )); - } else { - None - }; - - let new_token_id = TokenId::generate(); - let new_refresh_token = RefreshToken::generate(); - let new_expires_at = Utc::now() + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS); - - db::rotate_token( - &state.db, - db_id, - &new_token_id.0, - &new_refresh_token.0, - new_expires_at, - ) - .await?; - - let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?; - - let mut response_headers = HeaderMap::new(); - let config = AuthConfig::get(); - let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); - response_headers.insert( - "DPoP-Nonce", - verifier.generate_nonce().parse().unwrap(), - ); - - Ok(( - response_headers, - Json(TokenResponse { - access_token, - token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), - expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, - refresh_token: Some(new_refresh_token.0), - scope: token_data.scope, - sub: Some(token_data.did), - }), - )) -} - -fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> { - use subtle::ConstantTimeEq; - - let mut hasher = Sha256::new(); - hasher.update(code_verifier.as_bytes()); - let hash = hasher.finalize(); - let computed_challenge = URL_SAFE_NO_PAD.encode(&hash); - - if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) { - return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string())); - } - - Ok(()) -} - -fn create_access_token( - token_id: &str, - sub: &str, - dpop_jkt: Option<&str>, -) -> Result { - use serde_json::json; - - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let issuer = format!("https://{}", pds_hostname); - - let now = Utc::now().timestamp(); - let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; - - let mut payload = json!({ - "iss": issuer, - "sub": sub, - "aud": issuer, - "iat": now, - "exp": exp, - "jti": token_id, - "scope": "atproto" - }); - - if let Some(jkt) = dpop_jkt { - payload["cnf"] = json!({ "jkt": jkt }); - } - - let header = json!({ - "alg": "HS256", - "typ": "at+jwt" - }); - - let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); - let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); - - let signing_input = format!("{}.{}", header_b64, payload_b64); - - let config = AuthConfig::get(); - - use sha2::Sha256 as HmacSha256; - use hmac::{Hmac, Mac}; - type HmacSha256Type = Hmac; - - let mut mac = HmacSha256Type::new_from_slice(config.jwt_secret().as_bytes()) - .map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?; - mac.update(signing_input.as_bytes()); - let signature = mac.finalize().into_bytes(); - - let signature_b64 = URL_SAFE_NO_PAD.encode(&signature); - - Ok(format!("{}.{}", signing_input, signature_b64)) -} - -pub async fn revoke_token( - State(state): State, - Form(request): Form, -) -> Result { - if let Some(token) = &request.token { - if let Some((db_id, _)) = db::get_token_by_refresh_token(&state.db, token).await? { - db::delete_token_family(&state.db, db_id).await?; - } else { - db::delete_token(&state.db, token).await?; - } - } - - Ok(StatusCode::OK) -} - -#[derive(Debug, Deserialize)] -pub struct RevokeRequest { - pub token: Option, - #[serde(default)] - pub token_type_hint: Option, -} - -#[derive(Debug, Deserialize)] -pub struct IntrospectRequest { - pub token: String, - #[serde(default)] - pub token_type_hint: Option, -} - -#[derive(Debug, Serialize)] -pub struct IntrospectResponse { - pub active: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub username: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub token_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub iat: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub aud: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub iss: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub jti: Option, -} - -pub async fn introspect_token( - State(state): State, - Form(request): Form, -) -> Json { - let inactive_response = IntrospectResponse { - active: false, - scope: None, - client_id: None, - username: None, - token_type: None, - exp: None, - iat: None, - nbf: None, - sub: None, - aud: None, - iss: None, - jti: None, - }; - - let token_info = match extract_token_claims(&request.token) { - Ok(info) => info, - Err(_) => return Json(inactive_response), - }; - - let token_data = match db::get_token_by_id(&state.db, &token_info.jti).await { - Ok(Some(data)) => data, - _ => return Json(inactive_response), - }; - - if token_data.expires_at < Utc::now() { - return Json(inactive_response); - } - - let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let issuer = format!("https://{}", pds_hostname); - - Json(IntrospectResponse { - active: true, - scope: token_data.scope, - client_id: Some(token_data.client_id), - username: None, - token_type: if token_data.parameters.dpop_jkt.is_some() { - Some("DPoP".to_string()) - } else { - Some("Bearer".to_string()) - }, - exp: Some(token_info.exp), - iat: Some(token_info.iat), - nbf: Some(token_info.iat), - sub: Some(token_data.did), - aud: Some(issuer.clone()), - iss: Some(issuer), - jti: Some(token_info.jti), - }) -} - -struct TokenClaims { - jti: String, - exp: i64, - iat: i64, -} - -fn extract_token_claims(token: &str) -> Result { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 3 { - return Err(OAuthError::InvalidToken("Invalid token format".to_string())); - } - - let header_bytes = URL_SAFE_NO_PAD - .decode(parts[0]) - .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; - let header: serde_json::Value = serde_json::from_slice(&header_bytes) - .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; - - if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { - return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); - } - if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { - return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); - } - - let config = AuthConfig::get(); - let secret = config.jwt_secret(); - - let signing_input = format!("{}.{}", parts[0], parts[1]); - let provided_sig = URL_SAFE_NO_PAD - .decode(parts[2]) - .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; - - type HmacSha256 = hmac::Hmac; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) - .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; - mac.update(signing_input.as_bytes()); - let expected_sig = mac.finalize().into_bytes(); - - if !bool::from(expected_sig.ct_eq(&provided_sig)) { - return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); - } - - let payload_bytes = URL_SAFE_NO_PAD - .decode(parts[1]) - .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; - let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) - .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; - - let jti = payload - .get("jti") - .and_then(|j| j.as_str()) - .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? - .to_string(); - - let exp = payload - .get("exp") - .and_then(|e| e.as_i64()) - .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; - - let iat = payload - .get("iat") - .and_then(|i| i.as_i64()) - .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; - - Ok(TokenClaims { jti, exp, iat }) -} diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs new file mode 100644 index 0000000..f451bd4 --- /dev/null +++ b/src/oauth/endpoints/token/grants.rs @@ -0,0 +1,246 @@ +use axum::http::HeaderMap; +use axum::Json; +use chrono::{Duration, Utc}; + +use crate::config::AuthConfig; +use crate::state::AppState; +use crate::oauth::{ + ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, + client::{ClientMetadataCache, verify_client_auth}, + db, + dpop::DPoPVerifier, +}; + +use super::types::{TokenRequest, TokenResponse}; +use super::helpers::{create_access_token, verify_pkce}; + +const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; +const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60; + +pub async fn handle_authorization_code_grant( + state: AppState, + _headers: HeaderMap, + request: TokenRequest, + dpop_proof: Option, +) -> Result<(HeaderMap, Json), OAuthError> { + let code = request + .code + .ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?; + + let code_verifier = request + .code_verifier + .ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?; + + let auth_request = db::consume_authorization_request_by_code(&state.db, &code) + .await? + .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; + + if auth_request.expires_at < Utc::now() { + return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string())); + } + + if let Some(request_client_id) = &request.client_id { + if request_client_id != &auth_request.client_id { + return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); + } + } + + let did = auth_request + .did + .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; + + let client_metadata_cache = ClientMetadataCache::new(3600); + let client_metadata = client_metadata_cache + .get(&auth_request.client_id) + .await?; + let client_auth = auth_request.client_auth.clone().unwrap_or(ClientAuth::None); + verify_client_auth(&client_metadata, &client_auth)?; + + verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; + + if let Some(redirect_uri) = &request.redirect_uri { + if redirect_uri != &auth_request.parameters.redirect_uri { + return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string())); + } + } + + let dpop_jkt = if let Some(proof) = &dpop_proof { + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); + + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { + return Err(OAuthError::InvalidDpopProof( + "DPoP proof has already been used".to_string(), + )); + } + + if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt { + if &result.jkt != expected_jkt { + return Err(OAuthError::InvalidDpopProof( + "DPoP key binding mismatch".to_string(), + )); + } + } + + Some(result.jkt) + } else if auth_request.parameters.dpop_jkt.is_some() { + return Err(OAuthError::InvalidRequest( + "DPoP proof required for this authorization".to_string(), + )); + } else { + None + }; + + let token_id = TokenId::generate(); + let refresh_token = RefreshToken::generate(); + let now = Utc::now(); + + let access_token = create_access_token(&token_id.0, &did, dpop_jkt.as_deref())?; + + let token_data = TokenData { + did: did.clone(), + token_id: token_id.0.clone(), + created_at: now, + updated_at: now, + expires_at: now + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS), + client_id: auth_request.client_id.clone(), + client_auth: auth_request.client_auth.unwrap_or(ClientAuth::None), + device_id: auth_request.device_id, + parameters: auth_request.parameters.clone(), + details: None, + code: None, + current_refresh_token: Some(refresh_token.0.clone()), + scope: auth_request.parameters.scope.clone(), + }; + + db::create_token(&state.db, &token_data).await?; + + tokio::spawn({ + let pool = state.db.clone(); + let did_clone = did.clone(); + async move { + if let Err(e) = db::enforce_token_limit_for_user(&pool, &did_clone).await { + tracing::warn!("Failed to enforce token limit for user: {:?}", e); + } + } + }); + + let mut response_headers = HeaderMap::new(); + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + response_headers.insert( + "DPoP-Nonce", + verifier.generate_nonce().parse().unwrap(), + ); + + Ok(( + response_headers, + Json(TokenResponse { + access_token, + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + refresh_token: Some(refresh_token.0), + scope: auth_request.parameters.scope, + sub: Some(did), + }), + )) +} + +pub async fn handle_refresh_token_grant( + state: AppState, + _headers: HeaderMap, + request: TokenRequest, + dpop_proof: Option, +) -> Result<(HeaderMap, Json), OAuthError> { + let refresh_token_str = request + .refresh_token + .ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?; + + if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { + db::delete_token_family(&state.db, token_id).await?; + return Err(OAuthError::InvalidGrant( + "Refresh token reuse detected, token family revoked".to_string(), + )); + } + + let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str) + .await? + .ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?; + + if token_data.expires_at < Utc::now() { + db::delete_token_family(&state.db, db_id).await?; + return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string())); + } + + let dpop_jkt = if let Some(proof) = &dpop_proof { + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let token_endpoint = format!("https://{}/oauth/token", pds_hostname); + + let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?; + + if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? { + return Err(OAuthError::InvalidDpopProof( + "DPoP proof has already been used".to_string(), + )); + } + + if let Some(expected_jkt) = &token_data.parameters.dpop_jkt { + if &result.jkt != expected_jkt { + return Err(OAuthError::InvalidDpopProof( + "DPoP key binding mismatch".to_string(), + )); + } + } + + Some(result.jkt) + } else if token_data.parameters.dpop_jkt.is_some() { + return Err(OAuthError::InvalidRequest( + "DPoP proof required".to_string(), + )); + } else { + None + }; + + let new_token_id = TokenId::generate(); + let new_refresh_token = RefreshToken::generate(); + let new_expires_at = Utc::now() + Duration::days(REFRESH_TOKEN_EXPIRY_DAYS); + + db::rotate_token( + &state.db, + db_id, + &new_token_id.0, + &new_refresh_token.0, + new_expires_at, + ) + .await?; + + let access_token = create_access_token(&new_token_id.0, &token_data.did, dpop_jkt.as_deref())?; + + let mut response_headers = HeaderMap::new(); + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + response_headers.insert( + "DPoP-Nonce", + verifier.generate_nonce().parse().unwrap(), + ); + + Ok(( + response_headers, + Json(TokenResponse { + access_token, + token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), + expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, + refresh_token: Some(new_refresh_token.0), + scope: token_data.scope, + sub: Some(token_data.did), + }), + )) +} diff --git a/src/oauth/endpoints/token/helpers.rs b/src/oauth/endpoints/token/helpers.rs new file mode 100644 index 0000000..fd3030c --- /dev/null +++ b/src/oauth/endpoints/token/helpers.rs @@ -0,0 +1,143 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::Utc; +use hmac::Mac; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::config::AuthConfig; +use crate::oauth::OAuthError; + +const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600; + +pub struct TokenClaims { + pub jti: String, + pub exp: i64, + pub iat: i64, +} + +pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAuthError> { + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + let computed_challenge = URL_SAFE_NO_PAD.encode(&hash); + + if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) { + return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string())); + } + + Ok(()) +} + +pub fn create_access_token( + token_id: &str, + sub: &str, + dpop_jkt: Option<&str>, +) -> Result { + use serde_json::json; + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let issuer = format!("https://{}", pds_hostname); + + let now = Utc::now().timestamp(); + let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS; + + let mut payload = json!({ + "iss": issuer, + "sub": sub, + "aud": issuer, + "iat": now, + "exp": exp, + "jti": token_id, + "scope": "atproto" + }); + + if let Some(jkt) = dpop_jkt { + payload["cnf"] = json!({ "jkt": jkt }); + } + + let header = json!({ + "alg": "HS256", + "typ": "at+jwt" + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + + let signing_input = format!("{}.{}", header_b64, payload_b64); + + let config = AuthConfig::get(); + + type HmacSha256 = hmac::Hmac; + + let mut mac = HmacSha256::new_from_slice(config.jwt_secret().as_bytes()) + .map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?; + mac.update(signing_input.as_bytes()); + let signature = mac.finalize().into_bytes(); + + let signature_b64 = URL_SAFE_NO_PAD.encode(&signature); + + Ok(format!("{}.{}", signing_input, signature_b64)) +} + +pub fn extract_token_claims(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(OAuthError::InvalidToken("Invalid token format".to_string())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| OAuthError::InvalidToken("Invalid token encoding".to_string()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?; + + if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") { + return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string())); + } + if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") { + return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string())); + } + + let config = AuthConfig::get(); + let secret = config.jwt_secret(); + + let signing_input = format!("{}.{}", parts[0], parts[1]); + let provided_sig = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| OAuthError::InvalidToken("Invalid signature encoding".to_string()))?; + + type HmacSha256 = hmac::Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| OAuthError::ServerError("HMAC initialization failed".to_string()))?; + mac.update(signing_input.as_bytes()); + let expected_sig = mac.finalize().into_bytes(); + + if !bool::from(expected_sig.ct_eq(&provided_sig)) { + return Err(OAuthError::InvalidToken("Invalid token signature".to_string())); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| OAuthError::InvalidToken("Invalid payload encoding".to_string()))?; + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes) + .map_err(|_| OAuthError::InvalidToken("Invalid token payload".to_string()))?; + + let jti = payload + .get("jti") + .and_then(|j| j.as_str()) + .ok_or_else(|| OAuthError::InvalidToken("Missing jti claim".to_string()))? + .to_string(); + + let exp = payload + .get("exp") + .and_then(|e| e.as_i64()) + .ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?; + + let iat = payload + .get("iat") + .and_then(|i| i.as_i64()) + .ok_or_else(|| OAuthError::InvalidToken("Missing iat claim".to_string()))?; + + Ok(TokenClaims { jti, exp, iat }) +} diff --git a/src/oauth/endpoints/token/introspect.rs b/src/oauth/endpoints/token/introspect.rs new file mode 100644 index 0000000..c8a5d97 --- /dev/null +++ b/src/oauth/endpoints/token/introspect.rs @@ -0,0 +1,122 @@ +use axum::{Form, Json}; +use axum::extract::State; +use axum::http::StatusCode; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; +use crate::oauth::{OAuthError, db}; + +use super::helpers::extract_token_claims; + +#[derive(Debug, Deserialize)] +pub struct RevokeRequest { + pub token: Option, + #[serde(default)] + pub token_type_hint: Option, +} + +pub async fn revoke_token( + State(state): State, + Form(request): Form, +) -> Result { + if let Some(token) = &request.token { + if let Some((db_id, _)) = db::get_token_by_refresh_token(&state.db, token).await? { + db::delete_token_family(&state.db, db_id).await?; + } else { + db::delete_token(&state.db, token).await?; + } + } + + Ok(StatusCode::OK) +} + +#[derive(Debug, Deserialize)] +pub struct IntrospectRequest { + pub token: String, + #[serde(default)] + pub token_type_hint: Option, +} + +#[derive(Debug, Serialize)] +pub struct IntrospectResponse { + pub active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nbf: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub aud: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, +} + +pub async fn introspect_token( + State(state): State, + Form(request): Form, +) -> Json { + let inactive_response = IntrospectResponse { + active: false, + scope: None, + client_id: None, + username: None, + token_type: None, + exp: None, + iat: None, + nbf: None, + sub: None, + aud: None, + iss: None, + jti: None, + }; + + let token_info = match extract_token_claims(&request.token) { + Ok(info) => info, + Err(_) => return Json(inactive_response), + }; + + let token_data = match db::get_token_by_id(&state.db, &token_info.jti).await { + Ok(Some(data)) => data, + _ => return Json(inactive_response), + }; + + if token_data.expires_at < Utc::now() { + return Json(inactive_response); + } + + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + let issuer = format!("https://{}", pds_hostname); + + Json(IntrospectResponse { + active: true, + scope: token_data.scope, + client_id: Some(token_data.client_id), + username: None, + token_type: if token_data.parameters.dpop_jkt.is_some() { + Some("DPoP".to_string()) + } else { + Some("Bearer".to_string()) + }, + exp: Some(token_info.exp), + iat: Some(token_info.iat), + nbf: Some(token_info.iat), + sub: Some(token_data.did), + aud: Some(issuer.clone()), + iss: Some(issuer), + jti: Some(token_info.jti), + }) +} diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs new file mode 100644 index 0000000..0836730 --- /dev/null +++ b/src/oauth/endpoints/token/mod.rs @@ -0,0 +1,44 @@ +mod grants; +mod helpers; +mod introspect; +mod types; + +use axum::{ + Form, Json, + extract::State, + http::HeaderMap, +}; + +use crate::state::AppState; +use crate::oauth::OAuthError; + +pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant}; +pub use helpers::{create_access_token, extract_token_claims, verify_pkce, TokenClaims}; +pub use introspect::{ + introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest, +}; +pub use types::{TokenRequest, TokenResponse}; + +pub async fn token_endpoint( + State(state): State, + headers: HeaderMap, + Form(request): Form, +) -> Result<(HeaderMap, Json), OAuthError> { + let dpop_proof = headers + .get("DPoP") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + match request.grant_type.as_str() { + "authorization_code" => { + handle_authorization_code_grant(state, headers, request, dpop_proof).await + } + "refresh_token" => { + handle_refresh_token_grant(state, headers, request, dpop_proof).await + } + _ => Err(OAuthError::UnsupportedGrantType(format!( + "Unsupported grant_type: {}", + request.grant_type + ))), + } +} diff --git a/src/oauth/endpoints/token/types.rs b/src/oauth/endpoints/token/types.rs new file mode 100644 index 0000000..f595a2b --- /dev/null +++ b/src/oauth/endpoints/token/types.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub redirect_uri: Option, + #[serde(default)] + pub code_verifier: Option, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub client_id: Option, + #[serde(default)] + pub client_secret: Option, + #[serde(default)] + pub client_assertion: Option, + #[serde(default)] + pub client_assertion_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sub: Option, +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 37b669f0..9415c80 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -38,7 +38,8 @@ impl BlockStore for PostgresBlockStore { let mut hasher = Sha256::new(); hasher.update(data); let hash = hasher.finalize(); - let multihash = Multihash::wrap(0x12, &hash).unwrap(); + let multihash = Multihash::wrap(0x12, &hash) + .map_err(|e| RepoError::storage(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Failed to wrap multihash: {:?}", e))))?; let cid = Cid::new_v1(0x71, multihash); let cid_bytes = cid.to_bytes(); diff --git a/src/repo/tracking.rs b/src/repo/tracking.rs index 5170d45..a312cd8 100644 --- a/src/repo/tracking.rs +++ b/src/repo/tracking.rs @@ -21,7 +21,10 @@ impl TrackingBlockStore { } pub fn get_written_cids(&self) -> Vec { - self.written_cids.lock().unwrap().clone() + match self.written_cids.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } } } @@ -32,7 +35,10 @@ impl BlockStore for TrackingBlockStore { async fn put(&self, data: &[u8]) -> Result { let cid = self.inner.put(data).await?; - self.written_cids.lock().unwrap().push(cid.clone()); + match self.written_cids.lock() { + Ok(mut guard) => guard.push(cid.clone()), + Err(poisoned) => poisoned.into_inner().push(cid.clone()), + } Ok(cid) } @@ -47,7 +53,10 @@ impl BlockStore for TrackingBlockStore { let blocks: Vec<_> = blocks.into_iter().collect(); let cids: Vec = blocks.iter().map(|(cid, _)| cid.clone()).collect(); self.inner.put_many(blocks).await?; - self.written_cids.lock().unwrap().extend(cids); + match self.written_cids.lock() { + Ok(mut guard) => guard.extend(cids), + Err(poisoned) => poisoned.into_inner().extend(cids), + } Ok(()) } diff --git a/src/sync/blob.rs b/src/sync/blob.rs index 2d948d7..30f6e1e 100644 --- a/src/sync/blob.rs +++ b/src/sync/blob.rs @@ -132,7 +132,7 @@ pub async fn list_blobs( .into_response(); } - let limit = params.limit.unwrap_or(500).min(1000); + let limit = params.limit.unwrap_or(500).clamp(1, 1000); let cursor_cid = params.cursor.as_deref().unwrap_or(""); let user_result = sqlx::query!("SELECT id FROM users WHERE did = $1", did) diff --git a/src/sync/car.rs b/src/sync/car.rs index f271367..06136ee 100644 --- a/src/sync/car.rs +++ b/src/sync/car.rs @@ -23,12 +23,13 @@ pub fn ld_write(mut writer: W, data: &[u8]) -> std::io::Result<()> { Ok(()) } -pub fn encode_car_header(root_cid: &Cid) -> Vec { +pub fn encode_car_header(root_cid: &Cid) -> Result, String> { let header = CarHeader::new_v1(vec![root_cid.clone()]); - let header_cbor = header.encode().unwrap_or_default(); + let header_cbor = header.encode().map_err(|e| format!("Failed to encode CAR header: {:?}", e))?; let mut result = Vec::new(); - write_varint(&mut result, header_cbor.len() as u64).unwrap(); + write_varint(&mut result, header_cbor.len() as u64) + .expect("Writing to Vec should never fail"); result.extend_from_slice(&header_cbor); - result + Ok(result) } diff --git a/src/sync/commit.rs b/src/sync/commit.rs index 2e163f1..f7f7079 100644 --- a/src/sync/commit.rs +++ b/src/sync/commit.rs @@ -98,7 +98,7 @@ pub async fn list_repos( State(state): State, Query(params): Query, ) -> Response { - let limit = params.limit.unwrap_or(50).min(1000); + let limit = params.limit.unwrap_or(50).clamp(1, 1000); let cursor_did = params.cursor.as_deref().unwrap_or(""); let result = sqlx::query!( diff --git a/src/sync/frame.rs b/src/sync/frame.rs index 160f9a4..63fd176 100644 --- a/src/sync/frame.rs +++ b/src/sync/frame.rs @@ -38,22 +38,26 @@ pub struct RepoOp { pub cid: Option, } -impl From for CommitFrame { - fn from(event: SequencedEvent) -> Self { +impl TryFrom for CommitFrame { + type Error = &'static str; + + fn try_from(event: SequencedEvent) -> Result { let ops = serde_json::from_value::>(event.ops.unwrap_or_default()) .unwrap_or_else(|_| vec![]); - CommitFrame { + let commit_cid = event.commit_cid.ok_or("Missing commit_cid in event")?; + + Ok(CommitFrame { seq: event.seq, rebase: false, too_big: false, repo: event.did, - commit: event.commit_cid.unwrap_or_default(), + commit: commit_cid, prev: event.prev_cid, blocks: Vec::new(), ops, blobs: event.blobs.unwrap_or_default(), time: event.created_at.to_rfc3339(), - } + }) } } diff --git a/src/sync/relay_client.rs b/src/sync/relay_client.rs index 86e59e1..e37956e 100644 --- a/src/sync/relay_client.rs +++ b/src/sync/relay_client.rs @@ -12,12 +12,11 @@ async fn run_relay_client(state: AppState, url: String, ready_tx: Option { info!("Connected to firehose relay: {}", url); + let mut rx = state.firehose_tx.subscribe(); if let Some(tx) = ready_tx.as_ref() { tx.send(()).await.ok(); } - let mut rx = state.firehose_tx.subscribe(); - loop { tokio::select! { Ok(event) = rx.recv() => { diff --git a/src/sync/repo.rs b/src/sync/repo.rs index 4738b64..bccf793 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -15,6 +15,8 @@ use std::io::Write; use std::str::FromStr; use tracing::error; +const MAX_REPO_BLOCKS_TRAVERSAL: usize = 20_000; + #[derive(Deserialize)] pub struct GetBlocksQuery { pub did: String, @@ -52,13 +54,19 @@ pub async fn get_blocks( } }; - let root_cid = cids.first().cloned().unwrap_or_default(); - if cids.is_empty() { return (StatusCode::BAD_REQUEST, "No CIDs provided").into_response(); } - let header = encode_car_header(&root_cid); + let root_cid = cids[0]; + + let header = match encode_car_header(&root_cid) { + Ok(h) => h, + Err(e) => { + error!("Failed to encode CAR header: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to encode CAR").into_response(); + } + }; let mut car_bytes = header; @@ -69,9 +77,12 @@ pub async fn get_blocks( let total_len = cid_bytes.len() + block.len(); let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64).unwrap(); - writer.write_all(&cid_bytes).unwrap(); - writer.write_all(&block).unwrap(); + crate::sync::car::write_varint(&mut writer, total_len as u64) + .expect("Writing to Vec should never fail"); + writer.write_all(&cid_bytes) + .expect("Writing to Vec should never fail"); + writer.write_all(&block) + .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); } @@ -143,27 +154,39 @@ pub async fn get_repo( } }; - let mut car_bytes = encode_car_header(&head_cid); + let mut car_bytes = match encode_car_header(&head_cid) { + Ok(h) => h, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), + ) + .into_response(); + } + }; let mut stack = vec![head_cid]; let mut visited = std::collections::HashSet::new(); - let mut limit = 20000; + let mut remaining = MAX_REPO_BLOCKS_TRAVERSAL; while let Some(cid) = stack.pop() { if visited.contains(&cid) { continue; } visited.insert(cid); - if limit == 0 { break; } - limit -= 1; + if remaining == 0 { break; } + remaining -= 1; if let Ok(Some(block)) = state.block_store.get(&cid).await { let cid_bytes = cid.to_bytes(); let total_len = cid_bytes.len() + block.len(); let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64).unwrap(); - writer.write_all(&cid_bytes).unwrap(); - writer.write_all(&block).unwrap(); + crate::sync::car::write_varint(&mut writer, total_len as u64) + .expect("Writing to Vec should never fail"); + writer.write_all(&cid_bytes) + .expect("Writing to Vec should never fail"); + writer.write_all(&block) + .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); if let Ok(value) = serde_ipld_dagcbor::from_slice::(&block) { @@ -258,15 +281,23 @@ pub async fn get_record( _ => return (StatusCode::NOT_FOUND, "Block not found").into_response(), }; - let header = encode_car_header(&cid); + let header = match encode_car_header(&cid) { + Ok(h) => h, + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to encode CAR header: {}", e)).into_response(); + } + }; let mut car_bytes = header; let cid_bytes = cid.to_bytes(); let total_len = cid_bytes.len() + block.len(); let mut writer = Vec::new(); - crate::sync::car::write_varint(&mut writer, total_len as u64).unwrap(); - writer.write_all(&cid_bytes).unwrap(); - writer.write_all(&block).unwrap(); + crate::sync::car::write_varint(&mut writer, total_len as u64) + .expect("Writing to Vec should never fail"); + writer.write_all(&cid_bytes) + .expect("Writing to Vec should never fail"); + writer.write_all(&block) + .expect("Writing to Vec should never fail"); car_bytes.extend_from_slice(&writer); ( diff --git a/src/sync/subscribe_repos.rs b/src/sync/subscribe_repos.rs index 983f841..5f526cc 100644 --- a/src/sync/subscribe_repos.rs +++ b/src/sync/subscribe_repos.rs @@ -9,6 +9,8 @@ use futures::{sink::SinkExt, stream::StreamExt}; use serde::Deserialize; use tracing::{error, info, warn}; +const BACKFILL_BATCH_SIZE: i64 = 1000; + #[derive(Deserialize)] pub struct SubscribeReposParams { pub cursor: Option, @@ -37,32 +39,44 @@ async fn handle_socket(mut socket: WebSocket, state: AppState, params: Subscribe info!(cursor = ?params.cursor, "New firehose subscriber"); if let Some(cursor) = params.cursor { - let events = sqlx::query_as!( - SequencedEvent, - r#" - SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids - FROM repo_seq - WHERE seq > $1 - ORDER BY seq ASC - "#, - cursor - ) - .fetch_all(&state.db) - .await; + let mut current_cursor = cursor; + loop { + let events = sqlx::query_as!( + SequencedEvent, + r#" + SELECT seq, did, created_at, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids + FROM repo_seq + WHERE seq > $1 + ORDER BY seq ASC + LIMIT $2 + "#, + current_cursor, + BACKFILL_BATCH_SIZE + ) + .fetch_all(&state.db) + .await; - match events { - Ok(events) => { - for event in events { - if let Err(e) = send_event(&mut socket, &state, event).await { - warn!("Failed to send backfill event: {}", e); - return; + match events { + Ok(events) => { + if events.is_empty() { + break; + } + for event in &events { + current_cursor = event.seq; + if let Err(e) = send_event(&mut socket, &state, event.clone()).await { + warn!("Failed to send backfill event: {}", e); + return; + } + } + if (events.len() as i64) < BACKFILL_BATCH_SIZE { + break; } } - } - Err(e) => { - error!("Failed to fetch backfill events: {}", e); - socket.close().await.ok(); - return; + Err(e) => { + error!("Failed to fetch backfill events: {}", e); + socket.close().await.ok(); + return; + } } } } diff --git a/src/sync/util.rs b/src/sync/util.rs index c2be79d..34e4b80 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -2,24 +2,19 @@ use crate::state::AppState; use crate::sync::firehose::SequencedEvent; use crate::sync::frame::{CommitFrame, Frame, FrameData}; use cid::Cid; -use jacquard_repo::car::write_car; +use jacquard_repo::car::write_car_bytes; use jacquard_repo::storage::BlockStore; -use std::fs; use std::str::FromStr; -use tokio::fs::File; -use tokio::io::AsyncReadExt; -use uuid::Uuid; pub async fn format_event_for_sending( state: &AppState, event: SequencedEvent, ) -> Result, anyhow::Error> { let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); - let mut frame: CommitFrame = event.into(); + let mut frame: CommitFrame = event.try_into() + .map_err(|e| anyhow::anyhow!("Invalid event: {}", e))?; - let mut car_bytes = Vec::new(); - if !block_cids_str.is_empty() { - let temp_path = format!("/tmp/{}.car", Uuid::new_v4()); + let car_bytes = if !block_cids_str.is_empty() { let mut blocks = std::collections::BTreeMap::new(); for cid_str in block_cids_str { @@ -33,12 +28,10 @@ pub async fn format_event_for_sending( } let root = Cid::from_str(&frame.commit)?; - write_car(&temp_path, vec![root], blocks).await?; - - let mut file = File::open(&temp_path).await?; - file.read_to_end(&mut car_bytes).await?; - fs::remove_file(&temp_path)?; - } + write_car_bytes(root, blocks).await? + } else { + Vec::new() + }; frame.blocks = car_bytes; let frame = Frame { diff --git a/src/sync/verify.rs b/src/sync/verify.rs index adf21c7..c8c482a 100644 --- a/src/sync/verify.rs +++ b/src/sync/verify.rs @@ -302,345 +302,5 @@ pub struct VerifiedCar { } #[cfg(test)] -mod tests { - use super::*; - use sha2::{Digest, Sha256}; - - fn make_cid(data: &[u8]) -> Cid { - let mut hasher = Sha256::new(); - hasher.update(data); - let hash = hasher.finalize(); - let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); - Cid::new_v1(0x71, multihash) - } - - #[test] - fn test_verifier_creation() { - let _verifier = CarVerifier::new(); - } - - #[test] - fn test_verify_error_display() { - let err = VerifyError::DidMismatch { - commit_did: "did:plc:abc".to_string(), - expected_did: "did:plc:xyz".to_string(), - }; - assert!(err.to_string().contains("did:plc:abc")); - assert!(err.to_string().contains("did:plc:xyz")); - - let err = VerifyError::InvalidSignature; - assert!(err.to_string().contains("signature")); - - let err = VerifyError::NoSigningKey; - assert!(err.to_string().contains("signing key")); - - let err = VerifyError::MstValidationFailed("test error".to_string()); - assert!(err.to_string().contains("test error")); - } - - #[test] - fn test_mst_validation_missing_root_block() { - let verifier = CarVerifier::new(); - let blocks: HashMap = HashMap::new(); - - let fake_cid = make_cid(b"fake data"); - let result = verifier.verify_mst_structure(&fake_cid, &blocks); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - } - - #[test] - fn test_mst_validation_invalid_cbor() { - let verifier = CarVerifier::new(); - - let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]); - let cid = make_cid(&bad_cbor); - - let mut blocks = HashMap::new(); - blocks.insert(cid, bad_cbor); - - let result = verifier.verify_mst_structure(&cid, &blocks); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::InvalidCbor(_))); - } - - #[test] - fn test_mst_validation_empty_node() { - let verifier = CarVerifier::new(); - - let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ - "e": [] - })).unwrap(); - let cid = make_cid(&empty_node); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(empty_node)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } - - #[test] - fn test_mst_validation_missing_left_pointer() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - - let missing_left_cid = make_cid(b"missing left"); - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("l".to_string(), Ipld::Link(missing_left_cid)), - ("e".to_string(), Ipld::List(vec![])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - assert!(err.to_string().contains("left pointer")); - } - - #[test] - fn test_mst_validation_missing_subtree() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - - let missing_subtree_cid = make_cid(b"missing subtree"); - let record_cid = make_cid(b"record"); - - let entry = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"key1".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ("t".to_string(), Ipld::Link(missing_subtree_cid)), - ])); - - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::BlockNotFound(_))); - assert!(err.to_string().contains("subtree")); - } - - #[test] - fn test_mst_validation_unsorted_keys() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - - let record_cid = make_cid(b"record"); - - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::MstValidationFailed(_))); - assert!(err.to_string().contains("sorted")); - } - - #[test] - fn test_mst_validation_sorted_keys_ok() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - - let record_cid = make_cid(b"record"); - - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"bbb".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let entry3 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } - - #[test] - fn test_mst_validation_with_valid_left_pointer() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - - let left_node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![])), - ])); - let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap(); - let left_cid = make_cid(&left_node_bytes); - - let root_node = Ipld::Map(std::collections::BTreeMap::from([ - ("l".to_string(), Ipld::Link(left_cid)), - ("e".to_string(), Ipld::List(vec![])), - ])); - let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap(); - let root_cid = make_cid(&root_node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(root_cid, Bytes::from(root_node_bytes)); - blocks.insert(left_cid, Bytes::from(left_node_bytes)); - - let result = verifier.verify_mst_structure(&root_cid, &blocks); - assert!(result.is_ok()); - } - - #[test] - fn test_mst_validation_cycle_detection() { - let verifier = CarVerifier::new(); - - let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ - "e": [] - })).unwrap(); - let cid = make_cid(&node); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_unsupported_did_method() { - let verifier = CarVerifier::new(); - let result = verifier.resolve_did_document("did:unknown:test").await; - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::DidResolutionFailed(_))); - assert!(err.to_string().contains("Unsupported")); - } - - #[test] - fn test_mst_validation_with_prefix_compression() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"def".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - - let entry3 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"xyz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_ok(), "Prefix-compressed keys should be validated correctly"); - } - - #[test] - fn test_mst_validation_prefix_compression_unsorted() { - use ipld_core::ipld::Ipld; - - let verifier = CarVerifier::new(); - let record_cid = make_cid(b"record"); - - let entry1 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(0)), - ])); - - let entry2 = Ipld::Map(std::collections::BTreeMap::from([ - ("k".to_string(), Ipld::Bytes(b"abc".to_vec())), - ("v".to_string(), Ipld::Link(record_cid)), - ("p".to_string(), Ipld::Integer(19)), - ])); - - let node = Ipld::Map(std::collections::BTreeMap::from([ - ("e".to_string(), Ipld::List(vec![entry1, entry2])), - ])); - let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); - let cid = make_cid(&node_bytes); - - let mut blocks = HashMap::new(); - blocks.insert(cid, Bytes::from(node_bytes)); - - let result = verifier.verify_mst_structure(&cid, &blocks); - assert!(result.is_err(), "Unsorted prefix-compressed keys should fail validation"); - let err = result.unwrap_err(); - assert!(matches!(err, VerifyError::MstValidationFailed(_))); - } -} +#[path = "verify_tests.rs"] +mod tests; diff --git a/src/sync/verify_tests.rs b/src/sync/verify_tests.rs new file mode 100644 index 0000000..28fb7d8 --- /dev/null +++ b/src/sync/verify_tests.rs @@ -0,0 +1,346 @@ +#[cfg(test)] +mod tests { + use crate::sync::verify::{CarVerifier, VerifyError}; + use bytes::Bytes; + use cid::Cid; + use sha2::{Digest, Sha256}; + use std::collections::HashMap; + + fn make_cid(data: &[u8]) -> Cid { + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = hasher.finalize(); + let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap(); + Cid::new_v1(0x71, multihash) + } + + #[test] + fn test_verifier_creation() { + let _verifier = CarVerifier::new(); + } + + #[test] + fn test_verify_error_display() { + let err = VerifyError::DidMismatch { + commit_did: "did:plc:abc".to_string(), + expected_did: "did:plc:xyz".to_string(), + }; + assert!(err.to_string().contains("did:plc:abc")); + assert!(err.to_string().contains("did:plc:xyz")); + + let err = VerifyError::InvalidSignature; + assert!(err.to_string().contains("signature")); + + let err = VerifyError::NoSigningKey; + assert!(err.to_string().contains("signing key")); + + let err = VerifyError::MstValidationFailed("test error".to_string()); + assert!(err.to_string().contains("test error")); + } + + #[test] + fn test_mst_validation_missing_root_block() { + let verifier = CarVerifier::new(); + let blocks: HashMap = HashMap::new(); + + let fake_cid = make_cid(b"fake data"); + let result = verifier.verify_mst_structure(&fake_cid, &blocks); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); + } + + #[test] + fn test_mst_validation_invalid_cbor() { + let verifier = CarVerifier::new(); + + let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]); + let cid = make_cid(&bad_cbor); + + let mut blocks = HashMap::new(); + blocks.insert(cid, bad_cbor); + + let result = verifier.verify_mst_structure(&cid, &blocks); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::InvalidCbor(_))); + } + + #[test] + fn test_mst_validation_empty_node() { + let verifier = CarVerifier::new(); + + let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ + "e": [] + })).unwrap(); + let cid = make_cid(&empty_node); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(empty_node)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); + } + + #[test] + fn test_mst_validation_missing_left_pointer() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + + let missing_left_cid = make_cid(b"missing left"); + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("l".to_string(), Ipld::Link(missing_left_cid)), + ("e".to_string(), Ipld::List(vec![])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); + assert!(err.to_string().contains("left pointer")); + } + + #[test] + fn test_mst_validation_missing_subtree() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + + let missing_subtree_cid = make_cid(b"missing subtree"); + let record_cid = make_cid(b"record"); + + let entry = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"key1".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ("t".to_string(), Ipld::Link(missing_subtree_cid)), + ])); + + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![entry])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::BlockNotFound(_))); + assert!(err.to_string().contains("subtree")); + } + + #[test] + fn test_mst_validation_unsorted_keys() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + + let record_cid = make_cid(b"record"); + + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![entry1, entry2])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::MstValidationFailed(_))); + assert!(err.to_string().contains("sorted")); + } + + #[test] + fn test_mst_validation_sorted_keys_ok() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + + let record_cid = make_cid(b"record"); + + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"aaa".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"bbb".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let entry3 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"zzz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); + } + + #[test] + fn test_mst_validation_with_valid_left_pointer() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + + let left_node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![])), + ])); + let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap(); + let left_cid = make_cid(&left_node_bytes); + + let root_node = Ipld::Map(std::collections::BTreeMap::from([ + ("l".to_string(), Ipld::Link(left_cid)), + ("e".to_string(), Ipld::List(vec![])), + ])); + let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap(); + let root_cid = make_cid(&root_node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(root_cid, Bytes::from(root_node_bytes)); + blocks.insert(left_cid, Bytes::from(left_node_bytes)); + + let result = verifier.verify_mst_structure(&root_cid, &blocks); + assert!(result.is_ok()); + } + + #[test] + fn test_mst_validation_cycle_detection() { + let verifier = CarVerifier::new(); + + let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({ + "e": [] + })).unwrap(); + let cid = make_cid(&node); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_unsupported_did_method() { + let verifier = CarVerifier::new(); + let result = verifier.resolve_did_document("did:unknown:test").await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::DidResolutionFailed(_))); + assert!(err.to_string().contains("Unsupported")); + } + + #[test] + fn test_mst_validation_with_prefix_compression() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"def".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + + let entry3 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"xyz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_ok(), "Prefix-compressed keys should be validated correctly"); + } + + #[test] + fn test_mst_validation_prefix_compression_unsorted() { + use ipld_core::ipld::Ipld; + + let verifier = CarVerifier::new(); + let record_cid = make_cid(b"record"); + + let entry1 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(0)), + ])); + + let entry2 = Ipld::Map(std::collections::BTreeMap::from([ + ("k".to_string(), Ipld::Bytes(b"abc".to_vec())), + ("v".to_string(), Ipld::Link(record_cid)), + ("p".to_string(), Ipld::Integer(19)), + ])); + + let node = Ipld::Map(std::collections::BTreeMap::from([ + ("e".to_string(), Ipld::List(vec![entry1, entry2])), + ])); + let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap(); + let cid = make_cid(&node_bytes); + + let mut blocks = HashMap::new(); + blocks.insert(cid, Bytes::from(node_bytes)); + + let result = verifier.verify_mst_structure(&cid, &blocks); + assert!(result.is_err(), "Unsorted prefix-compressed keys should fail validation"); + let err = result.unwrap_err(); + assert!(matches!(err, VerifyError::MstValidationFailed(_))); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..7c32e81 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,103 @@ +use rand::Rng; +use sqlx::PgPool; +use uuid::Uuid; + +const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; + +pub fn generate_token_code() -> String { + generate_token_code_parts(2, 5) +} + +pub fn generate_token_code_parts(parts: usize, part_len: usize) -> String { + let mut rng = rand::thread_rng(); + let chars: Vec = BASE32_ALPHABET.chars().collect(); + + (0..parts) + .map(|_| { + (0..part_len) + .map(|_| chars[rng.gen_range(0..chars.len())]) + .collect::() + }) + .collect::>() + .join("-") +} + +#[derive(Debug)] +pub enum DbLookupError { + NotFound, + DatabaseError(sqlx::Error), +} + +impl From for DbLookupError { + fn from(e: sqlx::Error) -> Self { + DbLookupError::DatabaseError(e) + } +} + +pub async fn get_user_id_by_did(db: &PgPool, did: &str) -> Result { + sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + .fetch_optional(db) + .await? + .ok_or(DbLookupError::NotFound) +} + +pub struct UserInfo { + pub id: Uuid, + pub did: String, + pub handle: String, +} + +pub async fn get_user_by_did(db: &PgPool, did: &str) -> Result { + sqlx::query_as!( + UserInfo, + "SELECT id, did, handle FROM users WHERE did = $1", + did + ) + .fetch_optional(db) + .await? + .ok_or(DbLookupError::NotFound) +} + +pub async fn get_user_by_identifier(db: &PgPool, identifier: &str) -> Result { + sqlx::query_as!( + UserInfo, + "SELECT id, did, handle FROM users WHERE did = $1 OR handle = $1", + identifier + ) + .fetch_optional(db) + .await? + .ok_or(DbLookupError::NotFound) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_token_code() { + let code = generate_token_code(); + assert_eq!(code.len(), 11); + assert!(code.contains('-')); + + let parts: Vec<&str> = code.split('-').collect(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].len(), 5); + assert_eq!(parts[1].len(), 5); + + for c in code.chars() { + if c != '-' { + assert!(BASE32_ALPHABET.contains(c)); + } + } + } + + #[test] + fn test_generate_token_code_parts() { + let code = generate_token_code_parts(3, 4); + let parts: Vec<&str> = code.split('-').collect(); + assert_eq!(parts.len(), 3); + for part in parts { + assert_eq!(part.len(), 4); + } + } +} diff --git a/tests/email_update.rs b/tests/email_update.rs index 4adace2..085a7d4 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -556,5 +556,5 @@ async fn test_update_email_invalid_format() { assert_eq!(res.status(), StatusCode::BAD_REQUEST); let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "InvalidRequest"); + assert_eq!(body["error"], "InvalidEmail"); } diff --git a/tests/relay_client.rs b/tests/relay_client.rs index 23dc655..dcab6d2 100644 --- a/tests/relay_client.rs +++ b/tests/relay_client.rs @@ -13,13 +13,16 @@ use tokio::sync::mpsc; async fn mock_relay_server( listener: TcpListener, event_tx: mpsc::Sender>, - ready_tx: mpsc::Sender<()>, + connected_tx: mpsc::Sender<()>, ) { let handler = |ws: axum::extract::ws::WebSocketUpgrade| async { ws.on_upgrade(move |mut socket| async move { - ready_tx.send(()).await.unwrap(); - if let Some(Ok(Message::Binary(bytes))) = socket.recv().await { - event_tx.send(bytes.to_vec()).await.unwrap(); + let _ = connected_tx.send(()).await; + while let Some(Ok(msg)) = socket.recv().await { + if let Message::Binary(bytes) = msg { + let _ = event_tx.send(bytes.to_vec()).await; + break; + } } }) }; @@ -35,8 +38,8 @@ async fn test_outbound_relay_client() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let (event_tx, mut event_rx) = mpsc::channel(1); - let (ready_tx, ready_rx) = mpsc::channel(1); - tokio::spawn(mock_relay_server(listener, event_tx, ready_tx)); + let (connected_tx, _connected_rx) = mpsc::channel::<()>(1); + tokio::spawn(mock_relay_server(listener, event_tx, connected_tx)); let relay_url = format!("ws://{}", addr); let db_url = get_db_connection_string().await; @@ -46,23 +49,38 @@ async fn test_outbound_relay_client() { .unwrap(); let state = AppState::new(pool).await; + let (ready_tx, ready_rx) = mpsc::channel(1); start_relay_clients(state.clone(), vec![relay_url], Some(ready_rx)).await; - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tokio::time::timeout( + tokio::time::Duration::from_secs(5), + async { + ready_tx.closed().await; + } + ) + .await + .expect("Timeout waiting for relay client to be ready"); let dummy_event = SequencedEvent { seq: 1, did: "did:plc:test".to_string(), created_at: Utc::now(), event_type: "commit".to_string(), - commit_cid: None, + commit_cid: Some("bafyreihffx5a4o3qbv7vp6qmxpxok5mx5xvlsq6z4x3xv3zqv7vqvc7mzy".to_string()), prev_cid: None, - ops: None, - blobs: None, - blocks_cids: None, + ops: Some(serde_json::json!([])), + blobs: Some(vec![]), + blocks_cids: Some(vec![]), }; state.firehose_tx.send(dummy_event).unwrap(); - let received_bytes = event_rx.recv().await.expect("Did not receive event"); + let received_bytes = tokio::time::timeout( + tokio::time::Duration::from_secs(5), + event_rx.recv() + ) + .await + .expect("Timeout waiting for event") + .expect("Event channel closed"); + assert!(!received_bytes.is_empty()); }