Functional typesafe backend

This commit is contained in:
lewis
2026-01-04 15:18:26 +02:00
parent 1724a0792f
commit 3954189c22
116 changed files with 5346 additions and 6648 deletions
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trusted_at, trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trusted_at",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "trusted_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET deactivated_at = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5"
}
@@ -1,54 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "comms_type: String",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
false,
false
]
},
"hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email_verified FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "trusted_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "subject",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "body",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "public_key_did_key",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002"
}
@@ -1,108 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, recipient, subject, body,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\"\n FROM comms_queue\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"ordinal": 6,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
},
{
"ordinal": 7,
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
"kind": {
"Enum": [
"pending",
"processing",
"sent",
"failed"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "private_key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5"
}
@@ -1,60 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "comms_type: CommsType",
"type_info": {
"Custom": {
"name": "comms_type",
"kind": {
"Enum": [
"welcome",
"email_verification",
"password_reset",
"email_update",
"account_deletion",
"admin_email",
"plc_operation",
"two_factor_code",
"channel_verification",
"passkey_recovery",
"legacy_login_alert",
"migration_verification"
]
}
}
}
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true,
false,
false
]
},
"hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM users WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET is_admin = TRUE WHERE did = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_reset_code",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "password_reset_code_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382"
}
+39 -83
View File
@@ -1,3 +1,4 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use axum::{
Json,
@@ -7,7 +8,7 @@ use axum::{
};
use chrono::{Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::Value;
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
@@ -39,37 +40,25 @@ pub async fn get_preferences(
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
return ApiError::AuthenticationRequired.into_response();
}
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let has_full_access = auth_user.permissions().has_full_access();
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
let prefs_result = sqlx::query!(
@@ -81,11 +70,7 @@ pub async fn get_preferences(
let prefs = match prefs_result {
Ok(rows) => rows,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to fetch preferences"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to fetch preferences".into())).into_response();
}
};
let mut personal_details_pref: Option<Value> = None;
@@ -114,7 +99,7 @@ pub async fn get_preferences(
.and_then(|v| v.as_str())
.and_then(get_age_from_datestring)
{
let declared_age_pref = json!({
let declared_age_pref = serde_json::json!({
"$type": DECLARED_AGE_PREF,
"isOverAge13": age >= 13,
"isOverAge16": age >= 16,
@@ -139,92 +124,75 @@ pub async fn put_preferences(
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
return ApiError::AuthenticationRequired.into_response();
}
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let has_full_access = auth_user.permissions().has_full_access();
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
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();
return ApiError::InvalidRequest(format!(
"Too many preferences: {} exceeds limit of {}",
input.preferences.len(),
MAX_PREFERENCES_COUNT
))
.into_response();
}
let mut forbidden_prefs: Vec<String> = Vec::new();
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();
return ApiError::InvalidRequest(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 => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Preference is missing a $type"})),
)
return ApiError::InvalidRequest("Preference is missing a $type".into())
.into_response();
}
};
if !pref_type.starts_with(APP_BSKY_NAMESPACE) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": format!("Some preferences are not in the {} namespace", APP_BSKY_NAMESPACE)})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"Some preferences are not in the {} namespace",
APP_BSKY_NAMESPACE
))
.into_response();
}
if pref_type == PERSONAL_DETAILS_PREF && !has_full_access {
forbidden_prefs.push(pref_type.to_string());
}
}
if !forbidden_prefs.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": format!("Do not have authorization to set preferences: {}", forbidden_prefs.join(", "))})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"Do not have authorization to set preferences: {}",
forbidden_prefs.join(", ")
))
.into_response();
}
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to start transaction"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to start transaction".into())).into_response();
}
};
let delete_result = sqlx::query!(
@@ -237,11 +205,7 @@ pub async fn put_preferences(
.await;
if delete_result.is_err() {
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to clear preferences"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to clear preferences".into())).into_response();
}
for pref in input.preferences {
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
@@ -261,19 +225,11 @@ pub async fn put_preferences(
.await;
if insert_result.is_err() {
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to save preference"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to save preference".into())).into_response();
}
}
if tx.commit().await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to commit transaction".into())).into_response();
}
StatusCode::OK.into_response()
}
+21 -71
View File
@@ -1,18 +1,19 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::Did;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
pub struct DeleteAccountInput {
pub did: String,
pub did: Did,
}
pub async fn delete_account(
@@ -20,58 +21,35 @@ pub async fn delete_account(
_auth: BearerAuthAdmin,
Json(input): Json<DeleteAccountInput>,
) -> 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, handle FROM users WHERE did = $1", did)
let did = &input.did;
let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await;
let (user_id, handle) = match user {
Ok(Some(row)) => (row.id, row.handle),
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error in delete_account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did)
if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str())
.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();
return ApiError::InternalError(Some("Failed to delete session tokens".into())).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)
if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did.as_str())
.execute(&mut *tx)
.await
{
@@ -82,33 +60,21 @@ pub async fn delete_account(
.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();
return ApiError::InternalError(Some("Failed to delete records".into())).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();
return ApiError::InternalError(Some("Failed to delete repos".into())).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();
return ApiError::InternalError(Some("Failed to delete blobs".into())).into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id)
.execute(&mut *tx)
@@ -118,11 +84,7 @@ pub async fn delete_account(
"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();
return ApiError::InternalError(Some("Failed to delete app passwords".into())).into_response();
}
if let Err(e) = sqlx::query!(
"DELETE FROM invite_code_uses WHERE used_by_user = $1",
@@ -153,33 +115,21 @@ pub async fn delete_account(
.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();
return ApiError::InternalError(Some("Failed to delete user keys".into())).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();
return ApiError::InternalError(Some("Failed to delete user".into())).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();
return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response();
}
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
crate::api::repo::record::sequence_account_event(&state, did.as_str(), false, Some("deleted")).await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
@@ -187,5 +137,5 @@ pub async fn delete_account(
);
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
+11 -34
View File
@@ -1,5 +1,7 @@
use crate::api::error::{ApiError, AtpJson};
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::Did;
use axum::{
Json,
extract::State,
@@ -7,14 +9,13 @@ use axum::{
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 recipient_did: Did,
pub sender_did: Did,
pub content: String,
pub subject: Option<String>,
pub comment: Option<String>,
@@ -28,27 +29,15 @@ pub struct SendEmailOutput {
pub async fn send_email(
State(state): State<AppState>,
_auth: BearerAuthAdmin,
Json(input): Json<SendEmailInput>,
AtpJson(input): AtpJson<SendEmailInput>,
) -> 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();
return ApiError::InvalidRequest("content is required".into()).into_response();
}
let user = sqlx::query!(
"SELECT id, email, handle FROM users WHERE did = $1",
recipient_did
input.recipient_did.as_str()
)
.fetch_optional(&state.db)
.await;
@@ -57,29 +46,17 @@ pub async fn send_email(
let email = match row.email {
Some(e) => e,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
)
.into_response();
return ApiError::NoEmail.into_response();
}
};
(row.id, email, row.handle)
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error in send_email: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -97,7 +74,7 @@ pub async fn send_email(
let result = crate::comms::enqueue_comms(&state.db, item).await;
match result {
Ok(_) => {
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
tracing::info!("Admin email queued for {} ({})", handle, input.recipient_did);
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
}
Err(e) => {
+23 -46
View File
@@ -1,5 +1,7 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::{Did, Handle};
use axum::{
Json,
extract::{Query, RawQuery, State},
@@ -7,19 +9,18 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
pub struct GetAccountInfoParams {
pub did: String,
pub did: Did,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub indexed_at: String,
@@ -42,8 +43,8 @@ pub struct InviteCodeInfo {
pub code: String,
pub available: i32,
pub disabled: bool,
pub for_account: String,
pub created_by: String,
pub for_account: Did,
pub created_by: Did,
pub created_at: String,
pub uses: Vec<InviteCodeUseInfo>,
}
@@ -51,7 +52,7 @@ pub struct InviteCodeInfo {
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InviteCodeUseInfo {
pub used_by: String,
pub used_by: Did,
pub used_at: String,
}
@@ -66,21 +67,13 @@ pub async fn get_account_info(
_auth: BearerAuthAdmin,
Query(params): Query<GetAccountInfoParams>,
) -> 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 id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at
FROM users
WHERE did = $1
"#,
did
params.did.as_str()
)
.fetch_optional(&state.db)
.await;
@@ -91,8 +84,8 @@ pub async fn get_account_info(
(
StatusCode::OK,
Json(AccountInfo {
did: row.did,
handle: row.handle,
did: row.did.into(),
handle: row.handle.into(),
email: row.email,
indexed_at: row.created_at.to_rfc3339(),
invite_note: None,
@@ -109,18 +102,10 @@ pub async fn get_account_info(
)
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error in get_account_info: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -199,13 +184,13 @@ async fn get_invite_code_info(db: &sqlx::PgPool, code: &str) -> Option<InviteCod
code: row.code,
available: row.available_uses,
disabled: row.disabled.unwrap_or(false),
for_account: row.for_account,
created_by: row.created_by,
for_account: row.for_account.into(),
created_by: row.created_by.into(),
created_at: row.created_at.to_rfc3339(),
uses: uses
.into_iter()
.map(|u| InviteCodeUseInfo {
used_by: u.used_by,
used_by: u.used_by.into(),
used_at: u.used_at.to_rfc3339(),
})
.collect(),
@@ -222,11 +207,7 @@ pub async fn get_account_infos(
.filter(|d| !d.is_empty())
.collect();
if dids.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "dids is required"})),
)
.into_response();
return ApiError::InvalidRequest("dids is required".into()).into_response();
}
let users = match sqlx::query!(
r#"
@@ -242,11 +223,7 @@ pub async fn get_account_infos(
Ok(rows) => rows,
Err(e) => {
error!("Failed to fetch account infos: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -306,7 +283,7 @@ pub async fn get_account_infos(
.entry(u.code.clone())
.or_default()
.push(InviteCodeUseInfo {
used_by: u.used_by,
used_by: u.used_by.into(),
used_at: u.used_at.to_rfc3339(),
});
}
@@ -320,8 +297,8 @@ pub async fn get_account_infos(
code: ic.code.clone(),
available: ic.available_uses,
disabled: ic.disabled.unwrap_or(false),
for_account: ic.for_account,
created_by: ic.created_by,
for_account: ic.for_account.into(),
created_by: ic.created_by.into(),
created_at: ic.created_at.to_rfc3339(),
uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(),
};
@@ -339,8 +316,8 @@ pub async fn get_account_infos(
.and_then(|code| code_info_map.get(code).cloned());
let invites = codes_by_user.get(&row.id).cloned();
infos.push(AccountInfo {
did: row.did,
handle: row.handle,
did: row.did.into(),
handle: row.handle.into(),
email: row.email,
indexed_at: row.created_at.to_rfc3339(),
invite_note: None,
+8 -11
View File
@@ -1,5 +1,7 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::{Did, Handle};
use axum::{
Json,
extract::{Query, State},
@@ -7,7 +9,6 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
@@ -26,8 +27,8 @@ fn default_limit() -> i64 {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
pub indexed_at: String,
@@ -101,8 +102,8 @@ pub async fn search_accounts(
invites_disabled,
)| {
AccountView {
did: did.clone(),
handle,
did: did.clone().into(),
handle: handle.into(),
email,
indexed_at: created_at.to_rfc3339(),
email_confirmed_at: if email_verified {
@@ -117,7 +118,7 @@ pub async fn search_accounts(
)
.collect();
let next_cursor = if has_more {
accounts.last().map(|a| a.did.clone())
accounts.last().map(|a| a.did.to_string())
} else {
None
};
@@ -132,11 +133,7 @@ pub async fn search_accounts(
}
Err(e) => {
error!("DB error in search_accounts: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+31 -80
View File
@@ -1,13 +1,14 @@
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use crate::types::{Did, PlainPassword};
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, warn};
#[derive(Deserialize)]
@@ -24,11 +25,7 @@ pub async fn update_account_email(
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();
return ApiError::InvalidRequest("account and email are required".into()).into_response();
}
let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account)
.execute(&state.db)
@@ -36,28 +33,20 @@ pub async fn update_account_email(
match result {
Ok(r) => {
if r.rows_affected() == 0 {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error updating email: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountHandleInput {
pub did: String,
pub did: Did,
pub handle: String,
}
@@ -66,26 +55,16 @@ pub async fn update_account_handle(
_auth: BearerAuthAdmin,
Json(input): Json<UpdateAccountHandleInput>,
) -> Response {
let did = input.did.trim();
let did = &input.did;
let input_handle = input.handle.trim();
if did.is_empty() || input_handle.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
)
.into_response();
if input_handle.is_empty() {
return ApiError::InvalidRequest("handle is required".into()).into_response();
}
if !input_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();
return ApiError::InvalidHandle(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = if !input_handle.contains('.') {
@@ -93,7 +72,7 @@ pub async fn update_account_handle(
} else {
input_handle.to_string()
};
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
.ok()
@@ -101,62 +80,50 @@ pub async fn update_account_handle(
let existing = sqlx::query!(
"SELECT id FROM users WHERE handle = $1 AND did != $2",
handle,
did
did.as_str()
)
.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();
return ApiError::HandleTaken.into_response();
}
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did)
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did.as_str())
.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();
return ApiError::AccountNotFound.into_response();
}
if let Some(old) = old_handle {
let _ = state.cache.delete(&format!("handle:{}", old)).await;
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), Some(&handle)).await
{
warn!(
"Failed to sequence identity event for admin handle update: {}",
e
);
}
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await
if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await
{
warn!("Failed to update PLC handle for admin handle update: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error updating handle: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
#[derive(Deserialize)]
pub struct UpdateAccountPasswordInput {
pub did: String,
pub password: String,
pub did: Did,
pub password: PlainPassword,
}
pub async fn update_account_password(
@@ -164,51 +131,35 @@ pub async fn update_account_password(
_auth: BearerAuthAdmin,
Json(input): Json<UpdateAccountPasswordInput>,
) -> Response {
let did = input.did.trim();
let did = &input.did;
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();
if password.is_empty() {
return ApiError::InvalidRequest("password is required".into()).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();
return ApiError::InternalError(None).into_response();
}
};
let result = sqlx::query!(
"UPDATE users SET password_hash = $1 WHERE did = $2",
password_hash,
did
did.as_str()
)
.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();
return ApiError::AccountNotFound.into_response();
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error updating password: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+12 -39
View File
@@ -1,3 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use axum::{
@@ -7,7 +9,6 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::error;
#[derive(Deserialize)]
@@ -47,7 +48,7 @@ pub async fn disable_invite_codes(
}
}
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
@@ -145,11 +146,7 @@ pub async fn get_invite_codes(
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching invite codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let mut codes = Vec::new();
@@ -220,11 +217,7 @@ pub async fn disable_account_invites(
) -> Response {
let account = input.account.trim();
if account.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
)
.into_response();
return ApiError::InvalidRequest("account is required".into()).into_response();
}
let result = sqlx::query!(
"UPDATE users SET invites_disabled = TRUE WHERE did = $1",
@@ -235,21 +228,13 @@ pub async fn disable_account_invites(
match result {
Ok(r) => {
if r.rows_affected() == 0 {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error disabling account invites: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -266,11 +251,7 @@ pub async fn enable_account_invites(
) -> Response {
let account = input.account.trim();
if account.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account is required"})),
)
.into_response();
return ApiError::InvalidRequest("account is required".into()).into_response();
}
let result = sqlx::query!(
"UPDATE users SET invites_disabled = FALSE WHERE did = $1",
@@ -281,21 +262,13 @@ pub async fn enable_account_invites(
match result {
Ok(r) => {
if r.rows_affected() == 0 {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error enabling account invites: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+31 -79
View File
@@ -1,3 +1,4 @@
use crate::api::error::ApiError;
use crate::auth::BearerAuthAdmin;
use crate::state::AppState;
use axum::{
@@ -37,11 +38,7 @@ pub async fn get_subject_status(
Query(params): Query<GetSubjectStatusParams>,
) -> Response {
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Must provide did, uri, or blob"})),
)
.into_response();
return ApiError::InvalidRequest("Must provide did, uri, or blob".into()).into_response();
}
if let Some(did) = &params.did {
let user = sqlx::query!(
@@ -74,19 +71,11 @@ pub async fn get_subject_status(
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
)
.into_response();
return ApiError::SubjectNotFound.into_response();
}
Err(e) => {
error!("DB error in get_subject_status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
}
@@ -118,19 +107,11 @@ pub async fn get_subject_status(
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
)
.into_response();
return ApiError::RecordNotFound.into_response();
}
Err(e) => {
error!("DB error in get_subject_status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
}
@@ -138,11 +119,10 @@ pub async fn get_subject_status(
let did = match &params.did {
Some(d) => d,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Must provide a did to request blob state"})),
return ApiError::InvalidRequest(
"Must provide a did to request blob state".into(),
)
.into_response();
.into_response();
}
};
let blob = sqlx::query!(
@@ -172,27 +152,15 @@ pub async fn get_subject_status(
.into_response();
}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})),
)
.into_response();
return ApiError::BlobNotFound(None).into_response();
}
Err(e) => {
error!("DB error in get_subject_status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
}
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
)
.into_response()
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
}
#[derive(Deserialize)]
@@ -223,11 +191,7 @@ pub async fn update_subject_status(
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Some(takedown) = &input.takedown {
@@ -245,11 +209,10 @@ pub async fn update_subject_status(
.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();
return ApiError::InternalError(Some(
"Failed to update takedown status".into(),
))
.into_response();
}
}
if let Some(deactivated) = &input.deactivated {
@@ -270,20 +233,15 @@ pub async fn update_subject_status(
"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();
return ApiError::InternalError(Some(
"Failed to update deactivation status".into(),
))
.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 ApiError::InternalError(None).into_response();
}
if let Some(takedown) = &input.takedown {
let status = if takedown.applied {
@@ -363,11 +321,10 @@ pub async fn update_subject_status(
"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 ApiError::InternalError(Some(
"Failed to update takedown status".into(),
))
.into_response();
}
}
return (
@@ -401,11 +358,10 @@ pub async fn update_subject_status(
.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 ApiError::InternalError(Some(
"Failed to update takedown status".into(),
))
.into_response();
}
}
return (
@@ -423,9 +379,5 @@ pub async fn update_subject_status(
}
_ => {}
}
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})),
)
.into_response()
ApiError::InvalidRequest("Invalid subject type".into()).into_response()
}
+1 -1
View File
@@ -50,7 +50,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
}
};
let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", auth_user.did)
let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", &auth_user.did)
.fetch_optional(&state.db)
.await
{
+41 -165
View File
@@ -1,3 +1,5 @@
use crate::api::error::ApiError;
use crate::api::{EmptyResponse, EnabledResponse};
use crate::auth::BearerAuth;
use crate::scheduled::generate_full_backup;
use crate::state::AppState;
@@ -35,26 +37,18 @@ pub struct ListBackupsOutput {
pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response {
let user = match sqlx::query!(
"SELECT id, backup_enabled FROM users WHERE did = $1",
auth.0.did
auth.0.did.as_str()
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -73,11 +67,7 @@ pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Re
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching backups: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -116,11 +106,7 @@ pub async fn get_backup(
let backup_id = match uuid::Uuid::parse_str(&query.id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
)
.into_response();
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
}
};
@@ -132,39 +118,25 @@ pub async fn get_backup(
WHERE ab.id = $1 AND u.did = $2
"#,
backup_id,
auth.0.did
auth.0.did.as_str()
)
.fetch_optional(&state.db)
.await
{
Ok(Some(b)) => b,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
)
.into_response();
return ApiError::BackupNotFound.into_response();
}
Err(e) => {
error!("DB error fetching backup: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let backup_storage = match state.backup_storage.as_ref() {
Some(storage) => storage,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
),
)
.into_response();
return ApiError::BackupsDisabled.into_response();
}
};
@@ -172,11 +144,7 @@ pub async fn get_backup(
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to fetch backup from storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response();
}
};
@@ -207,13 +175,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
let backup_storage = match state.backup_storage.as_ref() {
Some(storage) => storage,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(
json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}),
),
)
.into_response();
return ApiError::BackupsDisabled.into_response();
}
};
@@ -224,58 +186,36 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
JOIN repos r ON r.user_id = u.id
WHERE u.did = $1
"#,
auth.0.did
auth.0.did.as_str()
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.deactivated_at.is_some() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
)
.into_response();
return ApiError::AccountDeactivated.into_response();
}
let repo_rev = match &user.repo_rev {
Some(rev) => rev.clone(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}),
),
)
.into_response();
return ApiError::RepoNotReady.into_response();
}
};
let head_cid = match Cid::from_str(&user.repo_root_cid) {
Ok(c) => c,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
)
.into_response();
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response();
}
};
@@ -283,11 +223,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
Ok(bytes) => bytes,
Err(e) => {
error!("Failed to generate CAR: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to generate backup"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to generate backup".into())).into_response();
}
};
@@ -301,11 +237,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
Ok(key) => key,
Err(e) => {
error!("Failed to upload backup: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store backup"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to store backup".into())).into_response();
}
};
@@ -335,11 +267,7 @@ pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> R
"Failed to rollback orphaned backup from S3"
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to record backup"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to record backup".into())).into_response();
}
};
@@ -420,11 +348,7 @@ pub async fn delete_backup(
let backup_id = match uuid::Uuid::parse_str(&query.id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})),
)
.into_response();
return ApiError::InvalidRequest("Invalid backup ID".into()).into_response();
}
};
@@ -436,35 +360,23 @@ pub async fn delete_backup(
WHERE ab.id = $1 AND u.did = $2
"#,
backup_id,
auth.0.did
auth.0.did.as_str()
)
.fetch_optional(&state.db)
.await
{
Ok(Some(b)) => b,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "BackupNotFound", "message": "Backup not found"})),
)
.into_response();
return ApiError::BackupNotFound.into_response();
}
Err(e) => {
error!("DB error fetching backup: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if backup.deactivated_at.is_some() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
)
.into_response();
return ApiError::AccountDeactivated.into_response();
}
if let Some(backup_storage) = state.backup_storage.as_ref()
@@ -482,16 +394,12 @@ pub async fn delete_backup(
.await
{
error!("DB error deleting backup: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to delete backup"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to delete backup".into())).into_response();
}
info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup");
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
@@ -507,78 +415,54 @@ pub async fn set_backup_enabled(
) -> Response {
let user = match sqlx::query!(
"SELECT deactivated_at FROM users WHERE did = $1",
auth.0.did
auth.0.did.as_str()
)
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.deactivated_at.is_some() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})),
)
.into_response();
return ApiError::AccountDeactivated.into_response();
}
if let Err(e) = sqlx::query!(
"UPDATE users SET backup_enabled = $1 WHERE did = $2",
input.enabled,
auth.0.did
auth.0.did.as_str()
)
.execute(&state.db)
.await
{
error!("DB error updating backup_enabled: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to update setting"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to update setting".into())).into_response();
}
info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting");
(StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response()
EnabledResponse::new(input.enabled).into_response()
}
pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response {
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did)
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -597,11 +481,7 @@ pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Re
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching blobs: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -695,11 +575,7 @@ pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Re
if let Err(e) = zip.finish() {
error!("Failed to finish zip: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create zip file"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to create zip file".into())).into_response();
}
}
+79 -310
View File
@@ -1,10 +1,11 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::BearerAuth;
use crate::delegation::{self, DelegationActionType};
use crate::oauth::db as oauth_db;
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle};
use crate::util::extract_client_ip;
use crate::validation::is_valid_did;
use axum::{
Json,
extract::{Query, State},
@@ -21,8 +22,8 @@ use tracing::{error, info, warn};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ControllerInfo {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
pub granted_scopes: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
pub is_active: bool,
@@ -38,14 +39,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to list controllers: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to list controllers"
})),
)
.into_response();
return ApiError::InternalError(Some("Failed to list controllers".into())).into_response();
}
};
@@ -53,7 +47,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
controllers: controllers
.into_iter()
.map(|c| ControllerInfo {
did: c.did,
did: c.did.into(),
handle: c.handle,
granted_scopes: c.granted_scopes,
granted_at: c.granted_at,
@@ -66,7 +60,7 @@ pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -
#[derive(Debug, Deserialize)]
pub struct AddControllerInput {
pub controller_did: String,
pub controller_did: Did,
pub granted_scopes: String,
}
@@ -75,67 +69,32 @@ pub async fn add_controller(
auth: BearerAuth,
Json(input): Json<AddControllerInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
return ApiError::InvalidScopes(e).into_response();
}
let controller_exists: bool = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#,
input.controller_did
input.controller_did.as_str()
)
.fetch_one(&state.db)
.await
.unwrap_or(false);
if !controller_exists {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "ControllerNotFound",
"message": "Controller account not found"
})),
)
.into_response();
return ApiError::ControllerNotFound.into_response();
}
match delegation::controls_any_accounts(&state.db, &auth.0.did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidDelegation",
"message": "Cannot add controllers to an account that controls other accounts"
})),
return ApiError::InvalidDelegation(
"Cannot add controllers to an account that controls other accounts".into(),
)
.into_response();
.into_response();
}
Err(e) => {
tracing::error!("Failed to check delegation status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to verify delegation status"
})),
)
return ApiError::InternalError(Some("Failed to verify delegation status".into()))
.into_response();
}
Ok(false) => {}
@@ -143,24 +102,14 @@ pub async fn add_controller(
match delegation::has_any_controllers(&state.db, &input.controller_did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidDelegation",
"message": "Cannot add a controlled account as a controller"
})),
return ApiError::InvalidDelegation(
"Cannot add a controlled account as a controller".into(),
)
.into_response();
.into_response();
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to verify controller status"
})),
)
return ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response();
}
Ok(false) => {}
@@ -200,21 +149,14 @@ pub async fn add_controller(
}
Err(e) => {
tracing::error!("Failed to add controller: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to add controller"
})),
)
.into_response()
ApiError::InternalError(Some("Failed to add controller".into())).into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveControllerInput {
pub controller_did: String,
pub controller_did: Did,
}
pub async fn remove_controller(
@@ -222,17 +164,6 @@ pub async fn remove_controller(
auth: BearerAuth,
Json(input): Json<RemoveControllerInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did)
.await
{
@@ -242,8 +173,8 @@ pub async fn remove_controller(
WHERE user_id = (SELECT id FROM users WHERE did = $1)
AND created_by_controller_did = $2
RETURNING id"#,
auth.0.did,
input.controller_did
&auth.0.did,
input.controller_did.as_str()
)
.fetch_all(&state.db)
.await
@@ -281,31 +212,17 @@ pub async fn remove_controller(
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "DelegationNotFound",
"message": "No active delegation found for this controller"
})),
)
.into_response(),
Ok(false) => ApiError::DelegationNotFound.into_response(),
Err(e) => {
tracing::error!("Failed to remove controller: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to remove controller"
})),
)
.into_response()
ApiError::InternalError(Some("Failed to remove controller".into())).into_response()
}
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateControllerScopesInput {
pub controller_did: String,
pub controller_did: Did,
pub granted_scopes: String,
}
@@ -314,26 +231,8 @@ pub async fn update_controller_scopes(
auth: BearerAuth,
Json(input): Json<UpdateControllerScopesInput>,
) -> Response {
if !is_valid_did(&input.controller_did) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": "Invalid DID format"
})),
)
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
return ApiError::InvalidScopes(e).into_response();
}
match delegation::update_delegation_scopes(
@@ -367,24 +266,10 @@ pub async fn update_controller_scopes(
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "DelegationNotFound",
"message": "No active delegation found for this controller"
})),
)
.into_response(),
Ok(false) => ApiError::DelegationNotFound.into_response(),
Err(e) => {
tracing::error!("Failed to update controller scopes: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to update controller scopes"
})),
)
.into_response()
ApiError::InternalError(Some("Failed to update controller scopes".into())).into_response()
}
}
}
@@ -392,8 +277,8 @@ pub async fn update_controller_scopes(
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DelegatedAccountInfo {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
pub granted_scopes: String,
pub granted_at: chrono::DateTime<chrono::Utc>,
}
@@ -408,13 +293,7 @@ pub async fn list_controlled_accounts(State(state): State<AppState>, auth: Beare
Ok(a) => a,
Err(e) => {
tracing::error!("Failed to list controlled accounts: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to list controlled accounts"
})),
)
return ApiError::InternalError(Some("Failed to list controlled accounts".into()))
.into_response();
}
};
@@ -423,7 +302,7 @@ pub async fn list_controlled_accounts(State(state): State<AppState>, auth: Beare
accounts: accounts
.into_iter()
.map(|a| DelegatedAccountInfo {
did: a.did,
did: a.did.into(),
handle: a.handle,
granted_scopes: a.granted_scopes,
granted_at: a.granted_at,
@@ -449,9 +328,9 @@ fn default_limit() -> i64 {
#[serde(rename_all = "camelCase")]
pub struct AuditLogEntry {
pub id: String,
pub delegated_did: String,
pub actor_did: String,
pub controller_did: Option<String>,
pub delegated_did: Did,
pub actor_did: Did,
pub controller_did: Option<Did>,
pub action_type: String,
pub action_details: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
@@ -478,14 +357,7 @@ pub async fn get_audit_log(
Ok(e) => e,
Err(e) => {
tracing::error!("Failed to get audit log: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "ServerError",
"message": "Failed to get audit log"
})),
)
.into_response();
return ApiError::InternalError(Some("Failed to get audit log".into())).into_response();
}
};
@@ -498,9 +370,9 @@ pub async fn get_audit_log(
.into_iter()
.map(|e| AuditLogEntry {
id: e.id.to_string(),
delegated_did: e.delegated_did,
actor_did: e.actor_did,
controller_did: e.controller_did,
delegated_did: e.delegated_did.into(),
actor_did: e.actor_did.into(),
controller_did: e.controller_did.map(Into::into),
action_type: format!("{:?}", e.action_type),
action_details: e.action_details,
created_at: e.created_at,
@@ -551,8 +423,8 @@ pub struct CreateDelegatedAccountInput {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDelegatedAccountResponse {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
}
pub async fn create_delegated_account(
@@ -567,47 +439,26 @@ pub async fn create_delegated_account(
.await
{
warn!(ip = %client_ip, "Delegated account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many account creation attempts. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(Some(
"Too many account creation attempts. Please try again later.".into(),
))
.into_response();
}
if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidScopes",
"message": e
})),
)
.into_response();
return ApiError::InvalidScopes(e).into_response();
}
match delegation::has_any_controllers(&state.db, &auth.0.did).await {
Ok(true) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidDelegation",
"message": "Cannot create delegated accounts from a controlled account"
})),
return ApiError::InvalidDelegation(
"Cannot create delegated accounts from a controlled account".into(),
)
.into_response();
.into_response();
}
Err(e) => {
tracing::error!("Failed to check controller status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": "ServerError",
"message": "Failed to verify controller status"
})),
)
return ApiError::InternalError(Some("Failed to verify controller status".into()))
.into_response();
}
Ok(false) => {}
@@ -628,11 +479,7 @@ pub async fn create_delegated_account(
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, hostname),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
)
.into_response();
return ApiError::InvalidRequest(e.to_string()).into_response();
}
}
} else {
@@ -647,11 +494,7 @@ pub async fn create_delegated_account(
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
return ApiError::InvalidEmail.into_response();
}
if let Some(ref code) = input.invite_code {
@@ -666,22 +509,14 @@ pub async fn create_delegated_account(
.unwrap_or(Some(false));
if valid != Some(true) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
)
.into_response();
return ApiError::InvalidInviteCode.into_response();
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if invite_required {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
)
.into_response();
return ApiError::InviteCodeRequired.into_response();
}
}
@@ -696,11 +531,7 @@ pub async fn create_delegated_account(
Ok(k) => k,
Err(e) => {
error!("Error creating signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -716,12 +547,7 @@ pub async fn create_delegated_account(
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": "Failed to create PLC operation"}),
),
)
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
.into_response();
}
};
@@ -732,28 +558,21 @@ pub async fn create_delegated_account(
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
return ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
))
.into_response();
}
let did = genesis_result.did;
info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account");
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Created DID for delegated account");
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
Err(e) => {
error!("Error starting transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -777,27 +596,13 @@ pub async fn create_delegated_account(
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
)
.into_response();
return ApiError::HandleNotAvailable(None).into_response();
} else if constraint.contains("email") {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidEmail", "message": "Email already registered"}),
),
)
.into_response();
return ApiError::EmailTaken.into_response();
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -805,11 +610,7 @@ pub async fn create_delegated_account(
Ok(bytes) => bytes,
Err(e) => {
error!("Error encrypting signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -823,30 +624,22 @@ pub async fn create_delegated_account(
.await
{
error!("Error inserting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query!(
r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)
VALUES ($1, $2, $3, $4)"#,
did,
auth.0.did,
&auth.0.did,
input.controller_scopes,
auth.0.did
&auth.0.did
)
.execute(&mut *tx)
.await
{
error!("Error creating initial delegation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let mst = Mst::new(Arc::new(state.block_store.clone()));
@@ -854,11 +647,7 @@ pub async fn create_delegated_account(
Ok(c) => c,
Err(e) => {
error!("Error persisting MST: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let rev = Tid::now(LimitedU32::MIN);
@@ -867,22 +656,14 @@ pub async fn create_delegated_account(
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
error!("Error saving genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid_str = commit_cid.to_string();
@@ -897,11 +678,7 @@ pub async fn create_delegated_account(
.await
{
error!("Error inserting repo: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
if let Err(e) = sqlx::query!(
@@ -917,11 +694,7 @@ pub async fn create_delegated_account(
.await
{
error!("Error inserting user_blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(ref code) = input.invite_code {
@@ -943,11 +716,7 @@ pub async fn create_delegated_account(
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) =
@@ -991,7 +760,7 @@ pub async fn create_delegated_account(
)
.await;
info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created");
info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created");
Json(CreateDelegatedAccountResponse { did, handle }).into_response()
Json(CreateDelegatedAccountResponse { did: did.into(), handle: handle.into() }).into_response()
}
+517 -55
View File
@@ -1,9 +1,10 @@
use axum::{
Json,
extract::{FromRequest, Request, rejection::JsonRejection},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde::{Serialize, de::DeserializeOwned};
use std::borrow::Cow;
#[derive(Debug, Serialize)]
@@ -15,24 +16,23 @@ struct ErrorBody<'a> {
#[derive(Debug)]
pub enum ApiError {
InternalError,
InternalError(Option<String>),
AuthenticationRequired,
AuthenticationFailed,
AuthenticationFailedMsg(String),
AuthenticationFailed(Option<String>),
InvalidRequest(String),
InvalidToken,
ExpiredToken,
ExpiredTokenMsg(String),
InvalidToken(Option<String>),
ExpiredToken(Option<String>),
TokenRequired,
AccountDeactivated,
AccountTakedown,
AccountNotFound,
RepoNotFound,
RepoNotFoundMsg(String),
RepoNotFound(Option<String>),
RepoTakendown,
RepoDeactivated,
RecordNotFound,
BlobNotFound,
InvalidHandle,
HandleNotAvailable,
BlobNotFound(Option<String>),
InvalidHandle(Option<String>),
HandleNotAvailable(Option<String>),
HandleTaken,
InvalidEmail,
EmailTaken,
@@ -40,10 +40,63 @@ pub enum ApiError {
DuplicateCreate,
DuplicateAppPassword,
AppPasswordNotFound,
InvalidSwap,
SessionNotFound,
InvalidSwap(Option<String>),
InvalidPassword(String),
InvalidRepo(String),
AccountMigrated,
AccountNotVerified,
InvalidCollection,
InvalidRecord(String),
Forbidden,
InsufficientScope,
AdminRequired,
InsufficientScope(Option<String>),
InvitesDisabled,
RateLimitExceeded(Option<String>),
PayloadTooLarge(String),
TotpAlreadyEnabled,
TotpNotEnabled,
InvalidCode(Option<String>),
InvalidChannel,
IdentifierMismatch,
NoPasskeys,
NoChallengeInProgress,
InvalidCredential,
PasskeyCounterAnomaly,
NoRegistrationInProgress,
RegistrationFailed,
PasskeyNotFound,
InvalidId,
InvalidScopes(String),
ControllerNotFound,
InvalidDelegation(String),
DelegationNotFound,
InviteCodeRequired,
BackupNotFound,
BackupsDisabled,
RepoNotReady,
DeviceNotFound,
NoEmail,
MfaVerificationRequired,
AuthorizationError(String),
InvalidDid(String),
InvalidSigningKey,
SetupExpired,
InvalidAccount,
InvalidRecoveryLink,
RecoveryLinkExpired,
MissingEmail,
MissingDiscordId,
MissingTelegramUsername,
MissingSignalNumber,
InvalidVerificationChannel,
SelfHostedDidWebDisabled,
AccountAlreadyExists,
HandleNotFound,
SubjectNotFound,
NotFoundMsg(String),
ServiceUnavailable(Option<String>),
UpstreamErrorMsg(String),
DatabaseError,
UpstreamFailure,
UpstreamTimeout,
@@ -58,48 +111,105 @@ pub enum ApiError {
impl ApiError {
fn status_code(&self) -> StatusCode {
match self {
Self::InternalError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => StatusCode::BAD_GATEWAY,
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
StatusCode::BAD_GATEWAY
}
Self::ServiceUnavailable(_) | Self::BackupsDisabled => {
StatusCode::SERVICE_UNAVAILABLE
}
Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT,
Self::UpstreamError { status, .. } => {
StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
}
Self::AuthenticationRequired
| Self::AuthenticationFailed
| Self::AuthenticationFailedMsg(_)
| Self::InvalidToken
| Self::ExpiredToken
| Self::ExpiredTokenMsg(_)
| Self::TokenRequired
| Self::AuthenticationFailed(_)
| Self::AccountDeactivated
| Self::AccountTakedown => StatusCode::UNAUTHORIZED,
Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => {
StatusCode::FORBIDDEN
}
| Self::AccountTakedown
| Self::InvalidCode(_)
| Self::InvalidPassword(_)
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
Self::Forbidden
| Self::AdminRequired
| Self::InsufficientScope(_)
| Self::InvitesDisabled
| Self::InvalidRepo(_)
| Self::AccountMigrated
| Self::AccountNotVerified
| Self::MfaVerificationRequired
| Self::AuthorizationError(_) => StatusCode::FORBIDDEN,
Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
Self::AccountNotFound
| Self::RepoNotFound
| Self::RepoNotFoundMsg(_)
| Self::RecordNotFound
| Self::BlobNotFound
| Self::AppPasswordNotFound => StatusCode::NOT_FOUND,
| Self::AppPasswordNotFound
| Self::SessionNotFound
| Self::DeviceNotFound
| Self::ControllerNotFound
| Self::DelegationNotFound
| Self::BackupNotFound
| Self::InvalidRecoveryLink
| Self::HandleNotFound
| Self::SubjectNotFound
| Self::BlobNotFound(_)
| Self::NotFoundMsg(_) => StatusCode::NOT_FOUND,
Self::RepoTakendown
| Self::RepoDeactivated
| Self::RepoNotFound(_) => StatusCode::BAD_REQUEST,
Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => {
StatusCode::CONFLICT
}
Self::InvalidRequest(_)
| Self::InvalidHandle
| Self::HandleNotAvailable
| Self::InvalidHandle(_)
| Self::HandleNotAvailable(_)
| Self::HandleTaken
| Self::InvalidEmail
| Self::EmailTaken
| Self::InvalidInviteCode
| Self::DuplicateCreate
| Self::DuplicateAppPassword
| Self::InvalidSwap => StatusCode::BAD_REQUEST,
| Self::InvalidCollection
| Self::InvalidRecord(_)
| Self::TotpNotEnabled
| Self::InvalidChannel
| Self::IdentifierMismatch
| Self::NoPasskeys
| Self::NoChallengeInProgress
| Self::InvalidCredential
| Self::NoEmail
| Self::NoRegistrationInProgress
| Self::RegistrationFailed
| Self::InvalidId
| Self::InvalidScopes(_)
| Self::InvalidDelegation(_)
| Self::InviteCodeRequired
| Self::RepoNotReady
| Self::InvalidDid(_)
| Self::InvalidSigningKey
| Self::SetupExpired
| Self::InvalidAccount
| Self::RecoveryLinkExpired
| Self::MissingEmail
| Self::MissingDiscordId
| Self::MissingTelegramUsername
| Self::MissingSignalNumber
| Self::InvalidVerificationChannel
| Self::SelfHostedDidWebDisabled
| Self::AccountAlreadyExists
| Self::InvalidToken(_)
| Self::ExpiredToken(_)
| Self::TokenRequired => StatusCode::BAD_REQUEST,
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
}
}
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => {
Cow::Borrowed("UpstreamFailure")
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalError"),
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
Cow::Borrowed("UpstreamError")
}
Self::ServiceUnavailable(_) => Cow::Borrowed("ServiceUnavailable"),
Self::NotFoundMsg(_) => Cow::Borrowed("NotFound"),
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
Self::UpstreamError { error, .. } => {
if let Some(e) = error {
@@ -108,43 +218,186 @@ impl ApiError {
Cow::Borrowed("UpstreamError")
}
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => {
Cow::Borrowed("AuthenticationFailed")
}
Self::InvalidToken => Cow::Borrowed("InvalidToken"),
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"),
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
Self::Forbidden => Cow::Borrowed("Forbidden"),
Self::InsufficientScope => Cow::Borrowed("InsufficientScope"),
Self::AdminRequired => Cow::Borrowed("AdminRequired"),
Self::InsufficientScope(_) => Cow::Borrowed("InsufficientScope"),
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
Self::RepoNotFound(_) => Cow::Borrowed("RepoNotFound"),
Self::RepoTakendown => Cow::Borrowed("RepoTakendown"),
Self::RepoDeactivated => Cow::Borrowed("RepoDeactivated"),
Self::RecordNotFound => Cow::Borrowed("RecordNotFound"),
Self::BlobNotFound => Cow::Borrowed("BlobNotFound"),
Self::BlobNotFound(_) => Cow::Borrowed("BlobNotFound"),
Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"),
Self::SessionNotFound => Cow::Borrowed("SessionNotFound"),
Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"),
Self::InvalidHandle => Cow::Borrowed("InvalidHandle"),
Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"),
Self::InvalidHandle(_) => Cow::Borrowed("InvalidHandle"),
Self::HandleNotAvailable(_) => Cow::Borrowed("HandleNotAvailable"),
Self::HandleTaken => Cow::Borrowed("HandleTaken"),
Self::InvalidEmail => Cow::Borrowed("InvalidEmail"),
Self::EmailTaken => Cow::Borrowed("EmailTaken"),
Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"),
Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"),
Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"),
Self::InvalidSwap => Cow::Borrowed("InvalidSwap"),
Self::InvalidSwap(_) => Cow::Borrowed("InvalidSwap"),
Self::InvalidPassword(_) => Cow::Borrowed("InvalidPassword"),
Self::InvalidRepo(_) => Cow::Borrowed("InvalidRepo"),
Self::AccountMigrated => Cow::Borrowed("AccountMigrated"),
Self::AccountNotVerified => Cow::Borrowed("AccountNotVerified"),
Self::InvalidCollection => Cow::Borrowed("InvalidCollection"),
Self::InvalidRecord(_) => Cow::Borrowed("InvalidRecord"),
Self::TotpAlreadyEnabled => Cow::Borrowed("TotpAlreadyEnabled"),
Self::TotpNotEnabled => Cow::Borrowed("TotpNotEnabled"),
Self::InvalidCode(_) => Cow::Borrowed("InvalidCode"),
Self::InvalidChannel => Cow::Borrowed("InvalidChannel"),
Self::IdentifierMismatch => Cow::Borrowed("IdentifierMismatch"),
Self::NoPasskeys => Cow::Borrowed("NoPasskeys"),
Self::NoChallengeInProgress => Cow::Borrowed("NoChallengeInProgress"),
Self::InvalidCredential => Cow::Borrowed("InvalidCredential"),
Self::PasskeyCounterAnomaly => Cow::Borrowed("PasskeyCounterAnomaly"),
Self::NoRegistrationInProgress => Cow::Borrowed("NoRegistrationInProgress"),
Self::RegistrationFailed => Cow::Borrowed("RegistrationFailed"),
Self::PasskeyNotFound => Cow::Borrowed("PasskeyNotFound"),
Self::InvalidId => Cow::Borrowed("InvalidId"),
Self::InvalidScopes(_) => Cow::Borrowed("InvalidScopes"),
Self::ControllerNotFound => Cow::Borrowed("ControllerNotFound"),
Self::InvalidDelegation(_) => Cow::Borrowed("InvalidDelegation"),
Self::DelegationNotFound => Cow::Borrowed("DelegationNotFound"),
Self::InviteCodeRequired => Cow::Borrowed("InviteCodeRequired"),
Self::BackupNotFound => Cow::Borrowed("BackupNotFound"),
Self::BackupsDisabled => Cow::Borrowed("BackupsDisabled"),
Self::RepoNotReady => Cow::Borrowed("RepoNotReady"),
Self::MfaVerificationRequired => Cow::Borrowed("MfaVerificationRequired"),
Self::RateLimitExceeded(_) => Cow::Borrowed("RateLimitExceeded"),
Self::PayloadTooLarge(_) => Cow::Borrowed("PayloadTooLarge"),
Self::DeviceNotFound => Cow::Borrowed("DeviceNotFound"),
Self::NoEmail => Cow::Borrowed("NoEmail"),
Self::AuthorizationError(_) => Cow::Borrowed("AuthorizationError"),
Self::InvalidDid(_) => Cow::Borrowed("InvalidDid"),
Self::InvalidSigningKey => Cow::Borrowed("InvalidSigningKey"),
Self::SetupExpired => Cow::Borrowed("SetupExpired"),
Self::InvalidAccount => Cow::Borrowed("InvalidAccount"),
Self::InvalidRecoveryLink => Cow::Borrowed("InvalidRecoveryLink"),
Self::RecoveryLinkExpired => Cow::Borrowed("RecoveryLinkExpired"),
Self::MissingEmail => Cow::Borrowed("MissingEmail"),
Self::MissingDiscordId => Cow::Borrowed("MissingDiscordId"),
Self::MissingTelegramUsername => Cow::Borrowed("MissingTelegramUsername"),
Self::MissingSignalNumber => Cow::Borrowed("MissingSignalNumber"),
Self::InvalidVerificationChannel => Cow::Borrowed("InvalidVerificationChannel"),
Self::SelfHostedDidWebDisabled => Cow::Borrowed("SelfHostedDidWebDisabled"),
Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"),
Self::HandleNotFound => Cow::Borrowed("HandleNotFound"),
Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"),
}
}
fn message(&self) -> Option<String> {
match self {
Self::AuthenticationFailedMsg(msg)
| Self::ExpiredTokenMsg(msg)
| Self::InvalidRequest(msg)
| Self::RepoNotFoundMsg(msg)
| Self::UpstreamUnavailable(msg) => Some(msg.clone()),
Self::InternalError(msg)
| Self::AuthenticationFailed(msg)
| Self::InvalidToken(msg)
| Self::ExpiredToken(msg)
| Self::RepoNotFound(msg)
| Self::BlobNotFound(msg)
| Self::InvalidHandle(msg)
| Self::HandleNotAvailable(msg)
| Self::InvalidSwap(msg)
| Self::InsufficientScope(msg)
| Self::InvalidCode(msg)
| Self::RateLimitExceeded(msg)
| Self::ServiceUnavailable(msg) => msg.clone(),
Self::InvalidRequest(msg)
| Self::UpstreamUnavailable(msg)
| Self::InvalidPassword(msg)
| Self::InvalidRepo(msg)
| Self::InvalidRecord(msg)
| Self::NotFoundMsg(msg)
| Self::UpstreamErrorMsg(msg)
| Self::PayloadTooLarge(msg) => Some(msg.clone()),
Self::AccountMigrated => Some(
"Account has been migrated to another PDS. Repo operations are not allowed."
.to_string(),
),
Self::AccountNotVerified => Some(
"You must verify at least one notification channel before creating records"
.to_string(),
),
Self::NoPasskeys => {
Some("No passkeys registered for this account".to_string())
}
Self::NoChallengeInProgress => Some(
"No passkey authentication in progress or challenge expired".to_string(),
),
Self::InvalidCredential => Some("Failed to parse credential response".to_string()),
Self::NoRegistrationInProgress => Some(
"No registration in progress. Call startPasskeyRegistration first.".to_string(),
),
Self::RegistrationFailed => {
Some("Failed to verify passkey registration".to_string())
}
Self::PasskeyNotFound => Some("Passkey not found".to_string()),
Self::InvalidId => Some("Invalid ID format".to_string()),
Self::InvalidScopes(msg) | Self::InvalidDelegation(msg) => Some(msg.clone()),
Self::ControllerNotFound => Some("Controller account not found".to_string()),
Self::DelegationNotFound => {
Some("No active delegation found for this controller".to_string())
}
Self::InviteCodeRequired => {
Some("An invite code is required to create an account".to_string())
}
Self::BackupNotFound => Some("Backup not found".to_string()),
Self::BackupsDisabled => Some("Backup storage not configured".to_string()),
Self::RepoNotReady => Some("Repository not ready for backup".to_string()),
Self::PasskeyCounterAnomaly => Some(
"Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".to_string(),
),
Self::MfaVerificationRequired => Some(
"This sensitive operation requires MFA verification".to_string(),
),
Self::DeviceNotFound => Some("Device not found".to_string()),
Self::NoEmail => Some("Recipient has no email address".to_string()),
Self::AuthorizationError(msg) | Self::InvalidDid(msg) => Some(msg.clone()),
Self::InvalidSigningKey => {
Some("Signing key not found, already used, or expired".to_string())
}
Self::SetupExpired => {
Some("Setup has already been completed or expired".to_string())
}
Self::InvalidAccount => {
Some("This account is not a passkey-only account".to_string())
}
Self::InvalidRecoveryLink => Some("Invalid recovery link".to_string()),
Self::RecoveryLinkExpired => Some("Recovery link has expired".to_string()),
Self::MissingEmail => {
Some("Email is required when using email verification".to_string())
}
Self::MissingDiscordId => {
Some("Discord ID is required when using Discord verification".to_string())
}
Self::MissingTelegramUsername => {
Some("Telegram username is required when using Telegram verification".to_string())
}
Self::MissingSignalNumber => {
Some("Signal phone number is required when using Signal verification".to_string())
}
Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()),
Self::SelfHostedDidWebDisabled => {
Some("Self-hosted did:web accounts are disabled on this server".to_string())
}
Self::AccountAlreadyExists => Some("Account already exists".to_string()),
Self::HandleNotFound => Some("Unable to resolve handle".to_string()),
Self::SubjectNotFound => Some("Subject not found".to_string()),
Self::IdentifierMismatch => {
Some("The identifier does not match the verification token".to_string())
}
Self::UpstreamError { message, .. } => message.clone(),
Self::UpstreamTimeout => Some("Upstream service timed out".to_string()),
Self::AdminRequired => Some("This action requires admin privileges".to_string()),
_ => None,
}
}
@@ -182,6 +435,7 @@ impl IntoResponse for ApiError {
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("Database error: {:?}", e);
@@ -194,9 +448,11 @@ impl From<crate::auth::TokenValidationError> for ApiError {
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,
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken,
crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError(None),
crate::auth::TokenValidationError::AuthenticationFailed => {
Self::AuthenticationFailed(None)
}
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None),
}
}
}
@@ -212,3 +468,209 @@ impl From<crate::util::DbLookupError> for ApiError {
}
}
}
impl From<crate::auth::extractor::AuthError> for ApiError {
fn from(e: crate::auth::extractor::AuthError) -> Self {
match e {
crate::auth::extractor::AuthError::MissingToken => Self::AuthenticationRequired,
crate::auth::extractor::AuthError::InvalidFormat => {
Self::AuthenticationFailed(Some("Invalid authorization header format".to_string()))
}
crate::auth::extractor::AuthError::AuthenticationFailed => {
Self::AuthenticationFailed(None)
}
crate::auth::extractor::AuthError::TokenExpired => {
Self::AuthenticationFailed(Some("Token has expired".to_string()))
}
crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated,
crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown,
crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired,
}
}
}
impl From<crate::handle::HandleResolutionError> for ApiError {
fn from(e: crate::handle::HandleResolutionError) -> Self {
match e {
crate::handle::HandleResolutionError::NotFound => Self::HandleNotFound,
crate::handle::HandleResolutionError::InvalidDid => {
Self::InvalidHandle(Some("Invalid DID format in handle record".to_string()))
}
crate::handle::HandleResolutionError::DidMismatch { expected, actual } => {
Self::InvalidHandle(Some(format!(
"Handle DID mismatch: expected {}, got {}",
expected, actual
)))
}
crate::handle::HandleResolutionError::DnsError(msg) => {
Self::InternalError(Some(format!("DNS resolution failed: {}", msg)))
}
crate::handle::HandleResolutionError::HttpError(msg) => {
Self::InternalError(Some(format!("Handle HTTP resolution failed: {}", msg)))
}
}
}
}
impl From<crate::auth::verification_token::VerifyError> for ApiError {
fn from(e: crate::auth::verification_token::VerifyError) -> Self {
use crate::auth::verification_token::VerifyError;
match e {
VerifyError::InvalidFormat => {
Self::InvalidRequest("The verification code is invalid or malformed".to_string())
}
VerifyError::UnsupportedVersion => {
Self::InvalidRequest("This verification code version is not supported".to_string())
}
VerifyError::Expired => {
Self::InvalidRequest("The verification code has expired. Please request a new one.".to_string())
}
VerifyError::InvalidSignature => {
Self::InvalidRequest("The verification code is invalid".to_string())
}
VerifyError::IdentifierMismatch => Self::IdentifierMismatch,
VerifyError::PurposeMismatch => {
Self::InvalidRequest("Verification code purpose does not match".to_string())
}
VerifyError::ChannelMismatch => {
Self::InvalidRequest("Verification code channel does not match".to_string())
}
}
}
}
impl From<crate::api::validation::HandleValidationError> for ApiError {
fn from(e: crate::api::validation::HandleValidationError) -> Self {
use crate::api::validation::HandleValidationError;
match e {
HandleValidationError::Reserved => Self::HandleNotAvailable(None),
HandleValidationError::BannedWord => {
Self::InvalidHandle(Some("Inappropriate language in handle".to_string()))
}
_ => Self::InvalidHandle(Some(e.to_string())),
}
}
}
impl From<jacquard::types::string::AtStrError> for ApiError {
fn from(e: jacquard::types::string::AtStrError) -> Self {
Self::InvalidRequest(format!("Invalid {}: {}", e.spec, e.kind))
}
}
impl From<crate::plc::PlcError> for ApiError {
fn from(e: crate::plc::PlcError) -> Self {
use crate::plc::PlcError;
match e {
PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()),
PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()),
PlcError::Timeout => Self::UpstreamTimeout,
PlcError::CircuitBreakerOpen => {
Self::ServiceUnavailable(Some("PLC directory service temporarily unavailable".into()))
}
PlcError::Http(err) => {
tracing::error!("PLC HTTP error: {:?}", err);
Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into())
}
PlcError::InvalidResponse(msg) => {
tracing::error!("PLC invalid response: {}", msg);
Self::UpstreamErrorMsg(format!("Invalid response from PLC directory: {}", msg))
}
PlcError::Serialization(msg) => {
tracing::error!("PLC serialization error: {}", msg);
Self::InternalError(Some(format!("PLC serialization error: {}", msg)))
}
PlcError::Signing(msg) => {
tracing::error!("PLC signing error: {}", msg);
Self::InternalError(Some(format!("PLC signing error: {}", msg)))
}
}
}
}
impl From<bcrypt::BcryptError> for ApiError {
fn from(e: bcrypt::BcryptError) -> Self {
tracing::error!("Bcrypt error: {:?}", e);
Self::InternalError(None)
}
}
impl From<cid::Error> for ApiError {
fn from(e: cid::Error) -> Self {
Self::InvalidRequest(format!("Invalid CID: {}", e))
}
}
impl From<crate::circuit_breaker::CircuitBreakerError<crate::plc::PlcError>> for ApiError {
fn from(e: crate::circuit_breaker::CircuitBreakerError<crate::plc::PlcError>) -> Self {
use crate::circuit_breaker::CircuitBreakerError;
match e {
CircuitBreakerError::CircuitOpen(err) => {
tracing::warn!("PLC directory circuit breaker open: {}", err);
Self::ServiceUnavailable(Some(
"PLC directory service temporarily unavailable".into(),
))
}
CircuitBreakerError::OperationFailed(plc_err) => Self::from(plc_err),
}
}
}
impl From<crate::storage::StorageError> for ApiError {
fn from(e: crate::storage::StorageError) -> Self {
tracing::error!("Storage error: {:?}", e);
Self::InternalError(Some("Storage operation failed".into()))
}
}
pub struct AtpJson<T>(pub T);
impl<T, S> FromRequest<S> for AtpJson<T>
where
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = (StatusCode, Json<serde_json::Value>);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match Json::<T>::from_request(req, state).await {
Ok(Json(value)) => Ok(AtpJson(value)),
Err(rejection) => {
let message = extract_json_error_message(&rejection);
Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "InvalidRequest",
"message": message
})),
))
}
}
}
}
fn extract_json_error_message(rejection: &JsonRejection) -> String {
match rejection {
JsonRejection::JsonDataError(e) => {
let inner = e.body_text();
if inner.contains("missing field") {
let field = inner
.split("missing field `")
.nth(1)
.and_then(|s| s.split('`').next())
.unwrap_or("unknown");
format!("Missing required field: {}", field)
} else if inner.contains("invalid type") {
format!("Invalid field type: {}", inner)
} else {
inner
}
}
JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(),
JsonRejection::MissingJsonContentType(_) => {
"Content-Type must be application/json".to_string()
}
JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(),
_ => "Invalid request body".to_string(),
}
}
+108 -353
View File
@@ -1,8 +1,10 @@
use super::did::verify_did_web;
use crate::api::error::ApiError;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle, PlainPassword};
use crate::validation::validate_password;
use axum::{
Json,
@@ -10,13 +12,13 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use serde_json::json;
use bcrypt::{DEFAULT_COST, hash};
use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use k256::{SecretKey, ecdsa::SigningKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{debug, error, info, warn};
@@ -40,7 +42,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
pub struct CreateAccountInput {
pub handle: String,
pub email: Option<String>,
pub password: String,
pub password: PlainPassword,
pub invite_code: Option<String>,
pub did: Option<String>,
pub did_type: Option<String>,
@@ -54,8 +56,8 @@ pub struct CreateAccountInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountOutput {
pub handle: String,
pub did: String,
pub handle: Handle,
pub did: Did,
#[serde(skip_serializing_if = "Option::is_none")]
pub did_doc: Option<serde_json::Value>,
pub access_jwt: String,
@@ -88,14 +90,8 @@ pub async fn create_account(
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many account creation attempts. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
.into_response();
}
let migration_auth = if let Some(token) =
@@ -113,14 +109,11 @@ pub async fn create_account(
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": format!("Service token verification failed: {}", e)
})),
)
.into_response();
return ApiError::AuthenticationFailed(Some(format!(
"Service token verification failed: {}",
e
)))
.into_response();
}
}
} else {
@@ -152,14 +145,11 @@ pub async fn create_account(
"[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}",
auth_did, provided_did
);
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
})),
)
.into_response();
return ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, provided_did
))
.into_response();
}
if is_did_web_byod {
info!(did = %provided_did, "Processing did:web BYOD account creation");
@@ -188,44 +178,26 @@ pub async fn create_account(
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => h,
Err(crate::api::validation::HandleValidationError::Reserved) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleNotAvailable", "message": "Reserved handle"})),
)
.into_response();
}
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
)
.into_response();
return ApiError::from(e).into_response();
}
}
} else {
if input.handle.contains(' ') || input.handle.contains('\t') {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})),
)
.into_response();
return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response();
}
for c in input.handle.chars() {
if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"Handle contains invalid character: {}",
c
))
.into_response();
}
}
let handle_lower = input.handle.to_lowercase();
if crate::moderation::has_explicit_slur(&handle_lower) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
)
return ApiError::InvalidRequest("Inappropriate language in handle".into())
.into_response();
}
handle_lower
@@ -238,20 +210,12 @@ pub async fn create_account(
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
return ApiError::InvalidEmail.into_response();
}
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&verification_channel) && !is_migration {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})),
)
.into_response();
return ApiError::InvalidVerificationChannel.into_response();
}
let verification_recipient = if is_migration {
None
@@ -259,36 +223,21 @@ pub async fn create_account(
Some(match verification_channel {
"email" => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
).into_response(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_id {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
).into_response(),
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
).into_response(),
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
).into_response(),
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
).into_response(),
_ => return ApiError::InvalidVerificationChannel.into_response(),
})
};
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -319,22 +268,11 @@ pub async fn create_account(
match reserved {
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidSigningKey",
"message": "Signing key not found, already used, or expired"
})),
)
.into_response();
return ApiError::InvalidSigningKey.into_response();
}
Err(e) => {
error!("Error looking up reserved signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
} else {
@@ -345,25 +283,14 @@ pub async fn create_account(
Ok(k) => k,
Err(e) => {
error!("Error creating signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let did_type = input.did_type.as_deref().unwrap_or("plc");
let did = match did_type {
"web" => {
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "SelfHostedDidWebDisabled",
"message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web."
})),
)
.into_response();
return ApiError::SelfHostedDidWebDisabled.into_response();
}
let subdomain_host = format!("{}.{}", input.handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
@@ -375,31 +302,21 @@ pub async fn create_account(
let d = match &input.did {
Some(d) if !d.trim().is_empty() => d,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
return ApiError::InvalidRequest(
"External did:web requires the 'did' field to be provided".into(),
)
.into_response();
.into_response();
}
};
if !d.starts_with("did:web:") {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
),
)
return ApiError::InvalidDid("External DID must be a did:web".into())
.into_response();
}
if !is_did_web_byod
&& let Err(e) =
verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
return ApiError::InvalidDid(e).into_response();
}
info!(did = %d, "Creating external did:web account");
d.clone()
@@ -419,19 +336,14 @@ pub async fn create_account(
)
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
return ApiError::InvalidDid(e).into_response();
}
d.clone()
} else if !d.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
return ApiError::InvalidDid(
"Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into()
)
.into_response();
.into_response();
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
@@ -444,11 +356,10 @@ pub async fn create_account(
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
.into_response();
return ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
))
.into_response();
}
};
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
@@ -457,14 +368,11 @@ pub async fn create_account(
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
return ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
))
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
@@ -481,11 +389,10 @@ pub async fn create_account(
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
.into_response();
return ApiError::InternalError(Some(
"Failed to create PLC operation".into(),
))
.into_response();
}
};
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
@@ -494,14 +401,11 @@ pub async fn create_account(
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
return ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
))
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
@@ -512,11 +416,7 @@ pub async fn create_account(
Ok(tx) => tx,
Err(e) => {
error!("Error starting transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if is_migration {
@@ -542,26 +442,14 @@ pub async fn create_account(
.map(|c| c.contains("handle"))
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})),
)
.into_response();
return ApiError::HandleTaken.into_response();
}
error!("Error reactivating account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
error!("Error committing reactivation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let key_row: Option<(Vec<u8>, i32)> = sqlx::query_as(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
@@ -576,21 +464,16 @@ pub async fn create_account(
Ok(k) => k,
Err(e) => {
error!("Error decrypting key for reactivated account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
}
None => {
error!("No signing key found for reactivated account");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Account signing key not found"})),
)
.into_response();
return ApiError::InternalError(Some(
"Account signing key not found".into(),
))
.into_response();
}
};
let access_meta =
@@ -598,11 +481,7 @@ pub async fn create_account(
Ok(m) => m,
Err(e) => {
error!("Error creating access token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(
@@ -612,11 +491,7 @@ pub async fn create_account(
Ok(m) => m,
Err(e) => {
error!("Error creating refresh token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let session_result: Result<_, sqlx::Error> = sqlx::query(
@@ -631,17 +506,13 @@ pub async fn create_account(
.await;
if let Err(e) = session_result {
error!("Error creating session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
return (
StatusCode::OK,
axum::http::StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone(),
did: did.clone(),
handle: handle.clone().into(),
did: did.clone().into(),
did_doc: state.did_resolver.resolve_did_document(&did).await,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
@@ -651,11 +522,7 @@ pub async fn create_account(
)
.into_response();
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})),
)
.into_response();
return ApiError::AccountAlreadyExists.into_response();
}
}
}
@@ -666,11 +533,7 @@ pub async fn create_account(
.await
.unwrap_or(None);
if exists_result.is_some() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleTaken", "message": "Handle already taken"})),
)
.into_response();
return ApiError::HandleTaken.into_response();
}
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
@@ -682,11 +545,7 @@ pub async fn create_account(
.map(|c| c.trim().is_empty())
.unwrap_or(true)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})),
)
.into_response();
return ApiError::InviteCodeRequired.into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
@@ -700,7 +559,7 @@ pub async fn create_account(
match invite_query {
Ok(Some(row)) => {
if row.available_uses <= 0 {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response();
return ApiError::InvalidInviteCode.into_response();
}
let update_invite = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
@@ -710,39 +569,20 @@ pub async fn create_account(
.await;
if let Err(e) = update_invite {
error!("Error updating invite code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})),
)
.into_response();
return ApiError::InvalidInviteCode.into_response();
}
Err(e) => {
error!("Error checking invite code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
}
if let Err(e) = validate_password(&input.password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let password_clone = input.password.clone();
@@ -751,19 +591,11 @@ pub async fn create_account(
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Error hashing password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
@@ -823,40 +655,15 @@ pub async fn create_account(
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") || constraint.contains("users_handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": "Handle already taken"
})),
)
.into_response();
return ApiError::HandleNotAvailable(None).into_response();
} else if constraint.contains("email") || constraint.contains("users_email") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidEmail",
"message": "Email already registered"
})),
)
.into_response();
return ApiError::EmailTaken.into_response();
} else if constraint.contains("did") || constraint.contains("users_did") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "AccountAlreadyExists",
"message": "An account with this DID already exists"
})),
)
.into_response();
return ApiError::AccountAlreadyExists.into_response();
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -864,11 +671,7 @@ pub async fn create_account(
Ok(enc) => enc,
Err(e) => {
error!("Error encrypting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let key_insert = sqlx::query!(
@@ -881,11 +684,7 @@ pub async fn create_account(
.await;
if let Err(e) = key_insert {
error!("Error inserting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(key_id) = reserved_key_id {
let mark_used = sqlx::query!(
@@ -896,11 +695,7 @@ pub async fn create_account(
.await;
if let Err(e) = mark_used {
error!("Error marking reserved key as used: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
let mst = Mst::new(Arc::new(state.block_store.clone()));
@@ -908,11 +703,7 @@ pub async fn create_account(
Ok(c) => c,
Err(e) => {
error!("Error persisting MST: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let rev = Tid::now(LimitedU32::MIN);
@@ -921,22 +712,14 @@ pub async fn create_account(
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
error!("Error saving genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid_str = commit_cid.to_string();
@@ -951,11 +734,7 @@ pub async fn create_account(
.await;
if let Err(e) = repo_insert {
error!("Error initializing repo: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
if let Err(e) = sqlx::query!(
@@ -971,11 +750,7 @@ pub async fn create_account(
.await
{
error!("Error inserting user_blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
@@ -989,11 +764,7 @@ pub async fn create_account(
.await;
if let Err(e) = use_insert {
error!("Error recording invite usage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
@@ -1016,11 +787,7 @@ pub async fn create_account(
}
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if !is_migration && !is_did_web_byod {
if let Err(e) =
@@ -1117,11 +884,7 @@ pub async fn create_account(
Ok(m) => m,
Err(e) => {
error!("createAccount: Error creating access token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let refresh_meta =
@@ -1129,11 +892,7 @@ pub async fn create_account(
Ok(m) => m,
Err(e) => {
error!("createAccount: Error creating refresh token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
@@ -1148,11 +907,7 @@ pub async fn create_account(
.await
{
error!("createAccount: Error creating session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let did_doc = state.did_resolver.resolve_did_document(&did).await;
@@ -1167,8 +922,8 @@ pub async fn create_account(
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone(),
did,
handle: handle.clone().into(),
did: did.into(),
did_doc,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
+56 -175
View File
@@ -1,4 +1,4 @@
use crate::api::ApiError;
use crate::api::{ApiError, DidResponse, EmptyResponse};
use crate::plc::signing_key_to_did_key;
use crate::state::AppState;
use axum::{
@@ -34,15 +34,11 @@ pub async fn resolve_handle(
) -> Response {
let handle = params.handle.trim();
if 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();
}
let cache_key = format!("handle:{}", handle);
if let Some(did) = state.cache.get(&cache_key).await {
return (StatusCode::OK, Json(json!({ "did": did }))).into_response();
return DidResponse::new(did).into_response();
}
let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
@@ -53,7 +49,7 @@ pub async fn resolve_handle(
.cache
.set(&cache_key, &row.did, std::time::Duration::from_secs(300))
.await;
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
DidResponse::new(row.did).into_response()
}
Ok(None) => match crate::handle::resolve_handle(handle).await {
Ok(did) => {
@@ -61,21 +57,13 @@ pub async fn resolve_handle(
.cache
.set(&cache_key, &did, std::time::Duration::from_secs(300))
.await;
(StatusCode::OK, Json(json!({ "did": did }))).into_response()
DidResponse::new(did).into_response()
}
Err(_) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})),
)
.into_response(),
Err(_) => ApiError::HandleNotFound.into_response(),
},
Err(e) => {
error!("DB error resolving handle: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -150,32 +138,21 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
let (user_id, did, migrated_to_pds) = match user {
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
return ApiError::NotFoundMsg("User not found".into()).into_response();
}
Err(e) => {
error!("DB Error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if !did.starts_with("did:web:") {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
)
.into_response();
return ApiError::NotFoundMsg("User is not did:web".into()).into_response();
}
let subdomain_host = format!("{}.{}", handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
if did != expected_self_hosted {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
)
return ApiError::NotFoundMsg("External did:web - DID document hosted by user".into())
.into_response();
}
@@ -235,30 +212,18 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
},
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate public key multibase: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -307,23 +272,15 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
let (user_id, did, migrated_to_pds) = match user {
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
return ApiError::NotFoundMsg("User not found".into()).into_response();
}
Err(e) => {
error!("DB Error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if !did.starts_with("did:web:") {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
)
.into_response();
return ApiError::NotFoundMsg("User is not did:web".into()).into_response();
}
let encoded_hostname = hostname.replace(':', "%3A");
let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
@@ -331,10 +288,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
if did != old_path_format && did != new_subdomain_format {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
)
return ApiError::NotFoundMsg("External did:web - DID document hosted by user".into())
.into_response();
}
@@ -394,30 +348,18 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
},
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate public key multibase: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -587,11 +529,7 @@ pub async fn get_recommended_did_credentials(
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
return ApiError::AuthenticationRequired.into_response();
}
};
let auth_user =
@@ -601,20 +539,20 @@ pub async fn get_recommended_did_credentials(
};
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
&auth_user.did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => return ApiError::InternalError.into_response(),
_ => return ApiError::InternalError(None).into_response(),
};
let key_bytes = match auth_user.key_bytes {
Some(kb) => kb,
None => {
return ApiError::AuthenticationFailedMsg(
return ApiError::AuthenticationFailed(Some(
"OAuth tokens cannot get DID credentials".into(),
)
))
.into_response();
}
};
@@ -622,7 +560,7 @@ pub async fn get_recommended_did_credentials(
let pds_endpoint = format!("https://{}", hostname);
let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(_) => return ApiError::InternalError.into_response(),
Err(_) => return ApiError::InternalError(None).into_response(),
};
let did_key = signing_key_to_did_key(&signing_key);
let rotation_keys = if auth_user.did.starts_with("did:web:") {
@@ -689,28 +627,22 @@ pub async fn update_handle(
.check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "RateLimitExceeded", "message": "Too many handle updates. Try again later."})),
)
.into_response();
return ApiError::RateLimitExceeded(Some("Too many handle updates. Try again later.".into(),))
.into_response();
}
if !state
.check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "RateLimitExceeded", "message": "Daily handle update limit exceeded."})),
)
return ApiError::RateLimitExceeded(Some("Daily handle update limit exceeded.".into()))
.into_response();
}
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did)
let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => return ApiError::InternalError.into_response(),
_ => return ApiError::InternalError(None).into_response(),
};
let user_id = user_row.id;
let current_handle = user_row.handle;
@@ -722,35 +654,21 @@ pub async fn update_handle(
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
),
)
return ApiError::InvalidHandle(Some("Handle contains invalid characters".into()))
.into_response();
}
for segment in new_handle.split('.') {
if segment.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle contains empty segment"})),
)
return ApiError::InvalidHandle(Some("Handle contains empty segment".into()))
.into_response();
}
if segment.starts_with('-') || segment.ends_with('-') {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle segment cannot start or end with hyphen"})),
)
.into_response();
return ApiError::InvalidHandle(Some("Handle segment cannot start or end with hyphen".into(),))
.into_response();
}
}
if crate::moderation::has_explicit_slur(&new_handle) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
)
return ApiError::InvalidHandle(Some("Inappropriate language in handle".into()))
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -774,31 +692,17 @@ pub async fn update_handle(
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
return (StatusCode::OK, Json(json!({}))).into_response();
return EmptyResponse::ok().into_response();
}
if short_part.contains('.') {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidHandle",
"message": "Nested subdomains are not allowed. Use a simple handle without dots."
})),
)
.into_response();
return ApiError::InvalidHandle(Some("Nested subdomains are not allowed. Use a simple handle without dots.".into(),))
.into_response();
}
if short_part.len() < 3 {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle too short"})),
)
.into_response();
return ApiError::InvalidHandle(Some("Handle too short".into())).into_response();
}
if short_part.len() > 18 {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle too long"})),
)
.into_response();
return ApiError::InvalidHandle(Some("Handle too long".into())).into_response();
}
full_handle
} else {
@@ -809,41 +713,26 @@ pub async fn update_handle(
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
return (StatusCode::OK, Json(json!({}))).into_response();
return EmptyResponse::ok().into_response();
}
match crate::handle::verify_handle_ownership(&new_handle, &did).await {
Ok(()) => {}
Err(crate::handle::HandleResolutionError::NotFound) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did",
"handle": new_handle
})),
)
.into_response();
return ApiError::HandleNotAvailable(None).into_response();
}
Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": format!("Handle points to different DID. Expected {}, got {}", expected, actual)
})),
)
.into_response();
return ApiError::HandleNotAvailable(Some(
format!("Handle points to different DID. Expected {}, got {}", expected, actual),
))
.into_response();
}
Err(e) => {
warn!("Handle verification failed: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "HandleNotAvailable",
"message": format!("Handle verification failed: {}", e)
})),
)
.into_response();
return ApiError::HandleNotAvailable(Some(format!(
"Handle verification failed: {}",
e
)))
.into_response();
}
}
new_handle.clone()
@@ -856,11 +745,7 @@ pub async fn update_handle(
.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();
return ApiError::HandleTaken.into_response();
}
let result = sqlx::query!(
"UPDATE users SET handle = $1 WHERE id = $2",
@@ -886,15 +771,11 @@ pub async fn update_handle(
if let Err(e) = update_plc_handle(&state, &did, &handle).await {
warn!("Failed to update PLC handle: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error updating handle: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+6 -12
View File
@@ -1,13 +1,11 @@
use crate::api::ApiError;
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{Duration, Utc};
use serde_json::json;
use tracing::{error, info, warn};
fn generate_plc_token() -> String {
@@ -36,7 +34,7 @@ pub async fn request_plc_operation_signature(
) {
return e;
}
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did)
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", &auth_user.did)
.fetch_optional(&state.db)
.await
{
@@ -44,7 +42,7 @@ pub async fn request_plc_operation_signature(
Ok(None) => return ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let _ = sqlx::query!(
@@ -68,11 +66,7 @@ pub async fn request_plc_operation_signature(
.await
{
error!("Failed to create PLC token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
@@ -84,5 +78,5 @@ pub async fn request_plc_operation_signature(
"PLC operation signature requested for user {}",
auth_user.did
);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
+21 -111
View File
@@ -1,8 +1,6 @@
use crate::api::ApiError;
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
use crate::plc::{
PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation,
};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation};
use crate::state::AppState;
use axum::{
Json,
@@ -13,9 +11,9 @@ use axum::{
use chrono::Utc;
use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::Value;
use std::collections::HashMap;
use tracing::{error, info, warn};
use tracing::{error, info};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -84,11 +82,7 @@ pub async fn sign_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
};
let token_row = match sqlx::query!(
@@ -101,22 +95,11 @@ pub async fn sign_plc_operation(
{
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidToken",
"message": "Invalid or expired token"
})),
)
.into_response();
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if Utc::now() > token_row.expires_at {
@@ -126,14 +109,7 @@ pub async fn sign_plc_operation(
)
.execute(&state.db)
.await;
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "ExpiredToken",
"message": "Token has expired"
})),
)
.into_response();
return ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
}
let key_row = match sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
@@ -144,11 +120,7 @@ pub async fn sign_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User signing key not found"})),
)
.into_response();
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
@@ -156,75 +128,28 @@ pub async fn sign_plc_operation(
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
let did_clone = did.clone();
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> =
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client.get_last_op(&did_clone).await
})
.await;
let last_op = match result {
let last_op = match with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client.get_last_op(&did_clone).await
})
.await
{
Ok(op) => op,
Err(CircuitBreakerError::CircuitOpen(e)) => {
warn!("PLC directory circuit breaker open: {}", e);
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "ServiceUnavailable",
"message": "PLC directory service temporarily unavailable"
})),
)
.into_response();
}
Err(CircuitBreakerError::OperationFailed(PlcError::NotFound)) => {
return (
StatusCode::NOT_FOUND,
Json(json!({
"error": "NotFound",
"message": "DID not found in PLC directory"
})),
)
.into_response();
}
Err(CircuitBreakerError::OperationFailed(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();
}
Err(e) => return ApiError::from(e).into_response(),
};
if last_op.is_tombstone() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "DID is tombstoned"
})),
)
.into_response();
return ApiError::from(PlcError::Tombstoned).into_response();
}
let services = input.services.map(|s| {
s.into_iter()
@@ -248,33 +173,18 @@ pub async fn sign_plc_operation(
) {
Ok(op) => op,
Err(PlcError::Tombstoned) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Cannot update tombstoned DID"
})),
)
.into_response();
return ApiError::InvalidRequest("Cannot update tombstoned DID".into()).into_response();
}
Err(e) => {
error!("Failed to create PLC operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let _ = sqlx::query!(
+24 -92
View File
@@ -1,16 +1,15 @@
use crate::api::ApiError;
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation};
use crate::api::{ApiError, EmptyResponse};
use crate::circuit_breaker::with_circuit_breaker;
use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use k256::ecdsa::SigningKey;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::Value;
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
@@ -64,11 +63,7 @@ pub async fn submit_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
};
let key_row = match sqlx::query!(
@@ -80,11 +75,7 @@ pub async fn submit_plc_operation(
{
Ok(Some(row)) => row,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User signing key not found"})),
)
.into_response();
return ApiError::InternalError(Some("User signing key not found".into())).into_response();
}
};
let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
@@ -92,22 +83,14 @@ pub async fn submit_plc_operation(
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let user_did_key = signing_key_to_did_key(&signing_key);
@@ -118,14 +101,10 @@ pub async fn submit_plc_operation(
.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"
})),
return ApiError::InvalidRequest(
"Rotation keys do not include server's rotation key".into(),
)
.into_response();
.into_response();
}
}
if let Some(services) = op.get("services").and_then(|v| v.as_object())
@@ -134,23 +113,11 @@ pub async fn submit_plc_operation(
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"
})),
)
return ApiError::InvalidRequest("Incorrect type on atproto_pds service".into())
.into_response();
}
if endpoint != Some(&public_url) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect endpoint on atproto_pds service"
})),
)
return ApiError::InvalidRequest("Incorrect endpoint on atproto_pds service".into())
.into_response();
}
}
@@ -158,13 +125,7 @@ pub async fn submit_plc_operation(
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
&& atproto_key != user_did_key
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Incorrect signing key in verificationMethods"
})),
)
return ApiError::InvalidRequest("Incorrect signing key in verificationMethods".into())
.into_response();
}
if let Some(also_known_as) = (!user.handle.is_empty())
@@ -174,50 +135,21 @@ pub async fn submit_plc_operation(
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"
})),
)
return ApiError::InvalidRequest("Incorrect handle in alsoKnownAs".into())
.into_response();
}
}
let plc_client = PlcClient::with_cache(None, Some(state.cache.clone()));
let operation_clone = input.operation.clone();
let did_clone = did.clone();
let result: Result<(), CircuitBreakerError<PlcError>> =
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client
.send_operation(&did_clone, &operation_clone)
.await
})
.await;
match result {
Ok(()) => {}
Err(CircuitBreakerError::CircuitOpen(e)) => {
warn!("PLC directory circuit breaker open: {}", e);
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"error": "ServiceUnavailable",
"message": "PLC directory service temporarily unavailable"
})),
)
.into_response();
}
Err(CircuitBreakerError::OperationFailed(e)) => {
error!("PLC operation failed: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to submit to PLC directory: {}", e)
})),
)
.into_response();
}
if let Err(e) = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client
.send_operation(&did_clone, &operation_clone)
.await
})
.await
{
return ApiError::from(e).into_response();
}
match sqlx::query!(
"INSERT INTO repo_seq (did, event_type, handle) VALUES ($1, 'identity', $2) RETURNING seq",
@@ -244,5 +176,5 @@ pub async fn submit_plc_operation(
warn!(did = %did, "Failed to refresh DID cache after PLC update");
}
info!(did = %did, "PLC operation submitted successfully");
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
+5
View File
@@ -10,10 +10,15 @@ pub mod notification_prefs;
pub mod proxy;
pub mod proxy_client;
pub mod repo;
pub mod responses;
pub mod server;
pub mod temp;
pub mod validation;
pub mod verification;
pub use error::ApiError;
pub use responses::{
DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse,
StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse,
};
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
+10 -28
View File
@@ -64,7 +64,7 @@ pub async fn create_report(
.await;
}
create_report_locally(&state, did, auth_user.is_takendown, input).await
create_report_locally(&state, did, auth_user.is_takendown(), input).await
}
async fn proxy_to_report_service(
@@ -76,10 +76,7 @@ async fn proxy_to_report_service(
) -> Response {
if let Err(e) = is_ssrf_safe(service_url) {
error!("Report service URL failed SSRF check: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Invalid report service configuration"})),
)
return ApiError::InternalError(Some("Invalid report service configuration".into()))
.into_response();
}
@@ -101,20 +98,20 @@ async fn proxy_to_report_service(
Ok(key) => key,
Err(e) => {
error!(error = ?e, "Failed to decrypt user key for report service auth");
return ApiError::AuthenticationFailedMsg(
return ApiError::AuthenticationFailed(Some(
"Failed to get signing key".into(),
)
))
.into_response();
}
}
}
Ok(None) => {
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
return ApiError::AuthenticationFailed(Some("User has no signing key".into()))
.into_response();
}
Err(e) => {
error!(error = ?e, "DB error fetching user key for report");
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
.into_response();
}
}
@@ -130,11 +127,7 @@ async fn proxy_to_report_service(
Ok(t) => t,
Err(e) => {
error!("Failed to create service token for report: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -208,10 +201,7 @@ async fn create_report_locally(
const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal";
if is_takendown && input.reason_type != REASON_APPEAL {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Report not accepted from takendown account"})),
)
return ApiError::InvalidRequest("Report not accepted from takendown account".into())
.into_response();
}
@@ -226,11 +216,7 @@ async fn create_report_locally(
];
if !valid_reason_types.contains(&input.reason_type.as_str()) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})),
)
.into_response();
return ApiError::InvalidRequest("Invalid reasonType".into()).into_response();
}
let created_at = chrono::Utc::now();
@@ -251,11 +237,7 @@ async fn create_report_locally(
if let Err(e) = insert {
error!("Failed to insert report: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
info!(
+36 -123
View File
@@ -1,9 +1,10 @@
use crate::api::error::ApiError;
use crate::auth::validate_bearer_token;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
@@ -29,20 +30,12 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
None => return ApiError::AuthenticationRequired.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let row =
@@ -66,13 +59,9 @@ pub async fn get_notification_prefs(State(state): State<AppState>, headers: Head
.await
{
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
let email: String = row.get("email");
let channel: String = row.get("channel");
@@ -120,36 +109,24 @@ pub async fn get_notification_history(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
None => return ApiError::AuthenticationRequired.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did)
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &user.did)
.fetch_one(&state.db)
.await
{
Ok(id) => id,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
let rows =
@@ -173,13 +150,9 @@ pub async fn get_notification_history(
.await
{
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
let sensitive_types = [
@@ -288,39 +261,27 @@ pub async fn update_notification_prefs(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
None => return ApiError::AuthenticationRequired.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let user_row =
match sqlx::query!(
"SELECT id, handle, email FROM users WHERE did = $1",
user.did
&user.did
)
.fetch_one(&state.db)
.await
{
Ok(row) => row,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response(),
Err(e) => {
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response()
}
};
let user_id = user_row.id;
@@ -332,14 +293,10 @@ pub async fn update_notification_prefs(
if let Some(ref channel) = input.preferred_channel {
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&channel.as_str()) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Invalid channel. Must be one of: email, discord, telegram, signal"
})),
return ApiError::InvalidRequest(
"Invalid channel. Must be one of: email, discord, telegram, signal".into(),
)
.into_response();
.into_response();
}
if let Err(e) = sqlx::query(
r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"#
@@ -349,11 +306,7 @@ pub async fn update_notification_prefs(
.execute(&state.db)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response();
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
}
@@ -361,19 +314,11 @@ pub async fn update_notification_prefs(
if let Some(ref new_email) = input.email {
let email_clean = new_email.trim().to_lowercase();
if email_clean.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})),
)
.into_response();
return ApiError::InvalidRequest("Email cannot be empty".into()).into_response();
}
if !crate::api::validation::is_valid_email(&email_clean) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
return ApiError::InvalidEmail.into_response();
}
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) {
@@ -388,11 +333,7 @@ pub async fn update_notification_prefs(
.await;
if let Ok(Some(_)) = exists {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already in use"})),
)
.into_response();
return ApiError::EmailTaken.into_response();
}
if let Err(e) = request_channel_verification(
@@ -405,11 +346,7 @@ pub async fn update_notification_prefs(
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
return ApiError::InternalError(Some(e)).into_response();
}
verification_required.push("email".to_string());
info!(did = %user.did, "Requested email verification");
@@ -425,11 +362,7 @@ pub async fn update_notification_prefs(
.execute(&state.db)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response();
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
info!(did = %user.did, "Cleared Discord ID");
} else {
@@ -438,11 +371,7 @@ pub async fn update_notification_prefs(
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
return ApiError::InternalError(Some(e)).into_response();
}
verification_required.push("discord".to_string());
info!(did = %user.did, "Requested Discord verification");
@@ -459,11 +388,7 @@ pub async fn update_notification_prefs(
.execute(&state.db)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response();
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
info!(did = %user.did, "Cleared Telegram username");
} else {
@@ -477,11 +402,7 @@ pub async fn update_notification_prefs(
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
return ApiError::InternalError(Some(e)).into_response();
}
verification_required.push("telegram".to_string());
info!(did = %user.did, "Requested Telegram verification");
@@ -497,11 +418,7 @@ pub async fn update_notification_prefs(
.execute(&state.db)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
)
.into_response();
return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response();
}
info!(did = %user.did, "Cleared Signal number");
} else {
@@ -509,11 +426,7 @@ pub async fn update_notification_prefs(
request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
return ApiError::InternalError(Some(e)).into_response();
}
verification_required.push("signal".to_string());
info!(did = %user.did, "Requested Signal verification");
+14 -42
View File
@@ -1,9 +1,9 @@
use std::convert::Infallible;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use axum::{
Json,
body::Bytes,
extract::{RawQuery, Request, State},
handler::Handler,
@@ -11,7 +11,6 @@ use axum::{
response::{IntoResponse, Response},
};
use futures_util::future::Either;
use serde_json::json;
use tower::{Service, util::BoxCloneSyncService};
use tracing::{error, info, warn};
@@ -120,44 +119,23 @@ async fn proxy_handler(
let method = uri.path().trim_start_matches("/");
if is_protected_method(&method) {
warn!(method = %method, "Attempted to proxy protected method");
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Cannot proxy protected method: {}", method)
})),
)
return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method))
.into_response();
}
let proxy_header = match headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) {
Some(h) => h.to_string(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Missing required atproto-proxy header"
})),
)
.into_response();
}
let Some(proxy_header) = headers
.get("atproto-proxy")
.and_then(|h| h.to_str().ok())
.map(String::from)
else {
return ApiError::InvalidRequest("Missing required atproto-proxy header".into())
.into_response();
};
let did = proxy_header.split('#').next().unwrap_or(&proxy_header);
let resolved = match state.did_resolver.resolve_did(did).await {
Some(r) => r,
None => {
error!(did = %did, "Could not resolve service DID");
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamFailure",
"message": "Could not resolve service DID"
})),
)
.into_response();
}
let Some(resolved) = state.did_resolver.resolve_did(did).await else {
error!(did = %did, "Could not resolve service DID");
return ApiError::UpstreamFailure.into_response();
};
let target_url = match &query {
@@ -220,14 +198,8 @@ async fn proxy_handler(
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
scheme
);
let mut response = (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "ExpiredToken",
"message": "Token has expired"
})),
)
.into_response();
let mut response =
ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
response
.headers_mut()
.insert("WWW-Authenticate", www_auth.parse().unwrap());
+33 -97
View File
@@ -1,3 +1,4 @@
use crate::api::error::ApiError;
use crate::auth::{ServiceTokenVerifier, is_service_token};
use crate::delegation::{self, DelegationActionType};
use crate::state::AppState;
@@ -23,17 +24,10 @@ pub async fn upload_blob(
headers: axum::http::HeaderMap,
body: Body,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
let Some(token) = 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();
}
) else {
return ApiError::AuthenticationRequired.into_response();
};
let is_service_auth = is_service_token(&token);
@@ -51,11 +45,11 @@ pub async fn upload_blob(
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": format!("Service token verification failed: {}", e)})),
)
.into_response();
return ApiError::AuthenticationFailed(Some(format!(
"Service token verification failed: {}",
e
)))
.into_response();
}
}
} else {
@@ -74,22 +68,18 @@ pub async fn upload_blob(
}
let deactivated = sqlx::query_scalar!(
"SELECT deactivated_at FROM users WHERE did = $1",
user.did
&user.did
)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten();
let ctrl_did = user.controller_did.clone();
(user.did, deactivated.is_some(), ctrl_did)
let ctrl_did = user.controller_did.map(|d| d.to_string());
(user.did.to_string(), deactivated.is_some(), ctrl_did)
}
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
}
};
@@ -98,14 +88,7 @@ pub async fn upload_blob(
.await
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountMigrated",
"message": "Account has been migrated to another PDS. Blob operations are not allowed."
})),
)
.into_response();
return ApiError::Forbidden.into_response();
}
let mime_type = headers
@@ -120,11 +103,7 @@ pub async fn upload_blob(
let user_id = match user_query {
Ok(Some(row)) => row.id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -143,22 +122,18 @@ pub async fn upload_blob(
Ok(result) => result,
Err(e) => {
error!("Failed to stream blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
}
};
let size = upload_result.size;
if size > max_size {
let _ = state.blob_store.delete(&temp_key).await;
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", size, max_size)})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"Blob size {} exceeds maximum of {} bytes",
size, max_size
))
.into_response();
}
let multihash = match Multihash::wrap(0x12, &upload_result.sha256_hash) {
@@ -166,11 +141,7 @@ pub async fn upload_blob(
Err(e) => {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to create multihash for blob: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to hash blob"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to hash blob".into())).into_response();
}
};
let cid = Cid::new_v1(0x55, multihash);
@@ -187,11 +158,7 @@ pub async fn upload_blob(
Err(e) => {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -212,22 +179,14 @@ pub async fn upload_blob(
Err(e) => {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to insert blob record: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await {
let _ = state.blob_store.delete(&temp_key).await;
error!("Failed to copy blob to final location: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to store blob"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to store blob".into())).into_response();
}
let _ = state.blob_store.delete(&temp_key).await;
@@ -240,11 +199,7 @@ pub async fn upload_blob(
storage_key, cleanup_err
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(ref controller) = controller_did {
@@ -303,41 +258,26 @@ pub async fn list_missing_blobs(
headers: axum::http::HeaderMap,
Query(params): Query<ListMissingBlobsParams>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
let Some(token) = 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();
}
) else {
return ApiError::AuthenticationRequired.into_response();
};
let auth_user =
match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
};
let did = auth_user.did;
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did)
let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await;
let user_id = match user_query {
Ok(Some(row)) => row.id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let limit = params.limit.unwrap_or(500).clamp(1, 1000);
@@ -361,11 +301,7 @@ pub async fn list_missing_blobs(
Ok(r) => r,
Err(e) => {
error!("DB error fetching missing blobs: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let has_more = rows.len() > limit as usize;
+83 -269
View File
@@ -1,13 +1,12 @@
use crate::api::ApiError;
use crate::api::error::ApiError;
use crate::api::repo::record::create_signed_commit;
use crate::api::EmptyResponse;
use crate::state::AppState;
use crate::sync::import::{ImportError, apply_import, parse_car};
use crate::sync::verify::CarVerifier;
use axum::{
Json,
body::Bytes,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use jacquard::types::{integer::LimitedU32, string::Tid};
@@ -28,13 +27,7 @@ pub async fn import_repo(
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if !accepting_imports {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Service is not accepting repo imports"
})),
)
return ApiError::InvalidRequest("Service is not accepting repo imports".into())
.into_response();
}
let max_size: usize = std::env::var("MAX_IMPORT_SIZE")
@@ -42,14 +35,11 @@ pub async fn import_repo(
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_IMPORT_SIZE);
if body.len() > max_size {
return (
StatusCode::PAYLOAD_TOO_LARGE,
Json(json!({
"error": "InvalidRequest",
"message": format!("Import size exceeds limit of {} bytes", max_size)
})),
)
.into_response();
return ApiError::PayloadTooLarge(format!(
"Import size exceeds limit of {} bytes",
max_size
))
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
@@ -72,64 +62,30 @@ pub async fn import_repo(
{
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.takedown_ref.is_some() {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountTakenDown",
"message": "Account has been taken down"
})),
)
.into_response();
return ApiError::AccountTakedown.into_response();
}
let user_id = user.id;
let (root, blocks) = match parse_car(&body).await {
Ok((r, b)) => (r, b),
Err(ImportError::InvalidRootCount) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Expected exactly one root in CAR file"
})),
)
return ApiError::InvalidRequest("Expected exactly one root in CAR file".into())
.into_response();
}
Err(ImportError::CarParse(msg)) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Failed to parse CAR file: {}", msg)
})),
)
return ApiError::InvalidRequest(format!("Failed to parse CAR file: {}", msg))
.into_response();
}
Err(e) => {
error!("CAR parsing error: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Invalid CAR file: {}", e)
})),
)
.into_response();
return ApiError::InvalidRequest(format!("Invalid CAR file: {}", e)).into_response();
}
};
info!(
@@ -138,44 +94,21 @@ pub async fn import_repo(
blocks.len(),
root
);
let root_block = match blocks.get(&root) {
Some(b) => b,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Root block not found in CAR file"
})),
)
.into_response();
}
let Some(root_block) = blocks.get(&root) else {
return ApiError::InvalidRequest("Root block not found in CAR file".into()).into_response();
};
let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit.did().to_string(),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Invalid commit: {}", e)
})),
)
.into_response();
return ApiError::InvalidRequest(format!("Invalid commit: {}", e)).into_response();
}
};
if commit_did != *did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "InvalidRequest",
"message": format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, did
)
})),
)
.into_response();
return ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, did
))
.into_response();
}
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.map(|v| v == "true" || v == "1")
@@ -197,37 +130,19 @@ pub async fn import_repo(
commit_did,
expected_did,
}) => {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "InvalidRequest",
"message": format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)
})),
)
.into_response();
return ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
))
.into_response();
}
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("MST validation failed: {}", msg)
})),
)
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
.into_response();
}
Err(e) => {
error!("CAR structure verification error: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("CAR verification failed: {}", e)
})),
)
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
.into_response();
}
}
@@ -245,68 +160,36 @@ pub async fn import_repo(
commit_did,
expected_did,
}) => {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "InvalidRequest",
"message": format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)
})),
)
.into_response();
return ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
))
.into_response();
}
Err(crate::sync::verify::VerifyError::InvalidSignature) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidSignature",
"message": "CAR file commit signature verification failed"
})),
return ApiError::InvalidRequest(
"CAR file commit signature verification failed".into(),
)
.into_response();
.into_response();
}
Err(crate::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
warn!("DID resolution failed during import verification: {}", msg);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Failed to verify DID: {}", msg)
})),
)
return ApiError::InvalidRequest(format!("Failed to verify DID: {}", msg))
.into_response();
}
Err(crate::sync::verify::VerifyError::NoSigningKey) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "DID document does not contain a signing key"
})),
return ApiError::InvalidRequest(
"DID document does not contain a signing key".into(),
)
.into_response();
.into_response();
}
Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("MST validation failed: {}", msg)
})),
)
return ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
.into_response();
}
Err(e) => {
error!("CAR verification error: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("CAR verification failed: {}", e)
})),
)
return ApiError::InvalidRequest(format!("CAR verification failed: {}", e))
.into_response();
}
}
@@ -364,19 +247,12 @@ pub async fn import_repo(
Ok(Some(row)) => row,
Ok(None) => {
error!("No signing key found for user {}", did);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Signing key not found"})),
)
return ApiError::InternalError(Some("Signing key not found".into()))
.into_response();
}
Err(e) => {
error!("DB error fetching signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let key_bytes =
@@ -384,22 +260,14 @@ pub async fn import_repo(
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt signing key: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let signing_key = match SigningKey::from_slice(&key_bytes) {
Ok(k) => k,
Err(e) => {
error!("Invalid signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let new_rev = Tid::now(LimitedU32::MIN);
@@ -414,22 +282,14 @@ pub async fn import_repo(
Ok(result) => result,
Err(e) => {
error!("Failed to create new commit: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let new_root_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
Ok(cid) => cid,
Err(e) => {
error!("Failed to store new commit block: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let new_root_str = new_root_cid.to_string();
@@ -443,11 +303,7 @@ pub async fn import_repo(
.await
{
error!("Failed to update repo root: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let mut all_block_cids: Vec<Vec<u8>> = blocks.keys().map(|c| c.to_bytes()).collect();
all_block_cids.push(new_root_cid.to_bytes());
@@ -464,11 +320,7 @@ pub async fn import_repo(
.await
{
error!("Failed to insert user_blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
info!(
"Created new commit for imported repo: cid={}, rev={}",
@@ -499,79 +351,41 @@ pub async fn import_repo(
);
}
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(ImportError::SizeLimitExceeded) => {
ApiError::PayloadTooLarge(format!("Import exceeds block limit of {}", max_blocks))
.into_response()
}
Err(ImportError::RepoNotFound) => {
ApiError::RepoNotFound(Some("Repository not initialized for this account".into()))
.into_response()
}
Err(ImportError::InvalidCbor(msg)) => {
ApiError::InvalidRequest(format!("Invalid CBOR data: {}", msg)).into_response()
}
Err(ImportError::InvalidCommit(msg)) => {
ApiError::InvalidRequest(format!("Invalid commit structure: {}", msg)).into_response()
}
Err(ImportError::BlockNotFound(cid)) => {
ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid))
.into_response()
}
Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some("Repository is being modified by another operation, please retry".into(),))
.into_response(),
Err(ImportError::VerificationFailed(ve)) => {
ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response()
}
Err(ImportError::DidMismatch { car_did, auth_did }) => {
ApiError::InvalidRequest(format!(
"CAR is for {} but authenticated as {}",
car_did, auth_did
))
.into_response()
}
Err(ImportError::SizeLimitExceeded) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Import exceeds block limit of {}", max_blocks)
})),
)
.into_response(),
Err(ImportError::RepoNotFound) => (
StatusCode::NOT_FOUND,
Json(json!({
"error": "RepoNotFound",
"message": "Repository not initialized for this account"
})),
)
.into_response(),
Err(ImportError::InvalidCbor(msg)) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Invalid CBOR data: {}", msg)
})),
)
.into_response(),
Err(ImportError::InvalidCommit(msg)) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Invalid commit structure: {}", msg)
})),
)
.into_response(),
Err(ImportError::BlockNotFound(cid)) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Referenced block not found in CAR: {}", cid)
})),
)
.into_response(),
Err(ImportError::ConcurrentModification) => (
StatusCode::CONFLICT,
Json(json!({
"error": "ConcurrentModification",
"message": "Repository is being modified by another operation, please retry"
})),
)
.into_response(),
Err(ImportError::VerificationFailed(ve)) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "VerificationFailed",
"message": format!("CAR verification failed: {}", ve)
})),
)
.into_response(),
Err(ImportError::DidMismatch { car_did, auth_did }) => (
StatusCode::FORBIDDEN,
Json(json!({
"error": "DidMismatch",
"message": format!("CAR is for {} but authenticated as {}", car_did, auth_did)
})),
)
.into_response(),
Err(e) => {
error!("Import error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+11 -17
View File
@@ -1,8 +1,9 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::AtIdentifier;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
@@ -10,7 +11,7 @@ use serde_json::json;
#[derive(Deserialize)]
pub struct DescribeRepoInput {
pub repo: String,
pub repo: AtIdentifier,
}
pub async fn describe_repo(
@@ -18,19 +19,20 @@ pub async fn describe_repo(
Query(input): Query<DescribeRepoInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user_row = if input.repo.starts_with("did:") {
let user_row = if input.repo.is_did() {
sqlx::query!(
"SELECT id, handle, did FROM users WHERE did = $1",
input.repo
input.repo.as_str()
)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
} else {
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
let repo_str = input.repo.as_str();
let handle = if !repo_str.contains('.') {
format!("{}.{}", repo_str, hostname)
} else {
input.repo.clone()
repo_str.to_string()
};
sqlx::query!(
"SELECT id, handle, did FROM users WHERE handle = $1",
@@ -43,18 +45,10 @@ pub async fn describe_repo(
let (user_id, handle, did) = match user_row {
Ok(Some((id, handle, did))) => (id, handle, did),
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
)
.into_response();
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let collections_query = sqlx::query!(
+79 -184
View File
@@ -1,10 +1,11 @@
use super::validation::validate_record_with_status;
use super::write::has_verified_comms_channel;
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use crate::validation::ValidationStatus;
use crate::types::{AtIdentifier, AtUri, Nsid, Rkey};
use axum::{
Json,
extract::State,
@@ -12,10 +13,6 @@ use axum::{
response::{IntoResponse, Response},
};
use cid::Cid;
use jacquard::types::{
integer::LimitedU32,
string::{Nsid, Tid},
};
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -30,24 +27,24 @@ const MAX_BATCH_WRITES: usize = 200;
pub enum WriteOp {
#[serde(rename = "com.atproto.repo.applyWrites#create")]
Create {
collection: String,
rkey: Option<String>,
collection: Nsid,
rkey: Option<Rkey>,
value: serde_json::Value,
},
#[serde(rename = "com.atproto.repo.applyWrites#update")]
Update {
collection: String,
rkey: String,
collection: Nsid,
rkey: Rkey,
value: serde_json::Value,
},
#[serde(rename = "com.atproto.repo.applyWrites#delete")]
Delete { collection: String, rkey: String },
Delete { collection: Nsid, rkey: Rkey },
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyWritesInput {
pub repo: String,
pub repo: AtIdentifier,
pub validate: Option<bool>,
pub writes: Vec<WriteOp>,
pub swap_commit: Option<String>,
@@ -58,14 +55,14 @@ pub struct ApplyWritesInput {
pub enum WriteResult {
#[serde(rename = "com.atproto.repo.applyWrites#createResult")]
CreateResult {
uri: String,
uri: AtUri,
cid: String,
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
validation_status: Option<String>,
},
#[serde(rename = "com.atproto.repo.applyWrites#updateResult")]
UpdateResult {
uri: String,
uri: AtUri,
cid: String,
#[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
validation_status: Option<String>,
@@ -96,51 +93,28 @@ pub async fn apply_writes(
input.repo,
input.writes.len()
);
let token = match crate::auth::extract_bearer_token_from_header(
let Some(token) = 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();
}
) else {
return ApiError::AuthenticationRequired.into_response();
};
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(_) => return ApiError::AuthenticationFailed(None).into_response(),
};
let did = auth_user.did.clone();
let is_oauth = auth_user.is_oauth;
let scope = auth_user.scope;
let controller_did = auth_user.controller_did.clone();
if input.repo != did {
return (
StatusCode::FORBIDDEN,
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
)
if input.repo.as_str() != did {
return ApiError::InvalidRepo("Repo does not match authenticated user".into())
.into_response();
}
if crate::util::is_account_migrated(&state.db, &did)
.await
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountMigrated",
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
})),
)
.into_response();
return ApiError::AccountMigrated.into_response();
}
let is_verified = has_verified_comms_channel(&state.db, &did)
.await
@@ -149,27 +123,13 @@ pub async fn apply_writes(
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response();
return ApiError::AccountNotVerified.into_response();
}
if input.writes.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "writes array is empty"})),
)
.into_response();
return ApiError::InvalidRequest("writes array is empty".into()).into_response();
}
if input.writes.len() > MAX_BATCH_WRITES {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": format!("Too many writes (max {})", MAX_BATCH_WRITES)})),
)
return ApiError::InvalidRequest(format!("Too many writes (max {})", MAX_BATCH_WRITES))
.into_response();
}
@@ -179,34 +139,34 @@ pub async fn apply_writes(
.unwrap_or(false);
if is_oauth || has_custom_scope {
use std::collections::HashSet;
let create_collections: HashSet<&str> = input
let create_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Create { collection, .. } = w {
Some(collection.as_str())
Some(collection)
} else {
None
}
})
.collect();
let update_collections: HashSet<&str> = input
let update_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Update { collection, .. } = w {
Some(collection.as_str())
Some(collection)
} else {
None
}
})
.collect();
let delete_collections: HashSet<&str> = input
let delete_collections: HashSet<&Nsid> = input
.writes
.iter()
.filter_map(|w| {
if let WriteOp::Delete { collection, .. } = w {
Some(collection.as_str())
Some(collection)
} else {
None
}
@@ -245,18 +205,12 @@ pub async fn apply_writes(
}
}
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("User not found".into())).into_response(),
};
let root_cid_str: String = match sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
@@ -266,53 +220,27 @@ pub async fn apply_writes(
.await
{
Ok(Some(cid_str)) => cid_str,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Repo root not found".into())).into_response(),
};
let current_root_cid = match Cid::from_str(&root_cid_str) {
Ok(c) => c,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
)
.into_response();
return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response()
}
};
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
};
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let mut mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
@@ -334,7 +262,7 @@ pub async fn apply_writes(
match validate_record_with_status(
value,
collection,
rkey.as_deref(),
rkey.as_ref().map(|r| r.as_str()),
require_lexicon,
) {
Ok(status) => Some(status),
@@ -342,51 +270,38 @@ pub async fn apply_writes(
}
};
all_blob_cids.extend(extract_blob_cids(value));
let rkey = rkey
.clone()
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
let rkey = rkey.clone().unwrap_or_else(Rkey::generate);
let record_ipld = crate::util::json_to_ipld(value);
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
return ApiError::InvalidRecord("Failed to serialize record".into())
.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(),
Err(_) => {
return ApiError::InternalError(Some("Failed to store record".into()))
.into_response()
}
};
let collection_nsid = match collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
};
let key = format!("{}/{}", collection_nsid, rkey);
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
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(),
Err(_) => {
return ApiError::InternalError(Some("Failed to add to MST".into()))
.into_response()
}
};
let uri = format!("at://{}/{}/{}", did, collection, rkey);
let uri = AtUri::from_parts(&did, collection, &rkey);
results.push(WriteResult::CreateResult {
uri,
cid: record_cid.to_string(),
validation_status: validation_status.map(|s| match s {
ValidationStatus::Valid => "valid".to_string(),
ValidationStatus::Unknown => "unknown".to_string(),
ValidationStatus::Invalid => "invalid".to_string(),
}),
validation_status: validation_status.map(|s| s.to_string()),
});
ops.push(RecordOp::Create {
collection: collection.clone(),
rkey,
collection: collection.to_string(),
rkey: rkey.to_string(),
cid: record_cid,
});
}
@@ -402,7 +317,7 @@ pub async fn apply_writes(
match validate_record_with_status(
value,
collection,
Some(rkey),
Some(rkey.as_str()),
require_lexicon,
) {
Ok(status) => Some(status),
@@ -413,66 +328,54 @@ pub async fn apply_writes(
let record_ipld = crate::util::json_to_ipld(value);
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
return ApiError::InvalidRecord("Failed to serialize record".into())
.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(),
Err(_) => {
return ApiError::InternalError(Some("Failed to store record".into()))
.into_response()
}
};
let collection_nsid = match collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
};
let key = format!("{}/{}", collection_nsid, rkey);
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
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(),
Err(_) => {
return ApiError::InternalError(Some("Failed to update MST".into()))
.into_response()
}
};
let uri = format!("at://{}/{}/{}", did, collection, rkey);
let uri = AtUri::from_parts(&did, collection, rkey);
results.push(WriteResult::UpdateResult {
uri,
cid: record_cid.to_string(),
validation_status: validation_status.map(|s| match s {
ValidationStatus::Valid => "valid".to_string(),
ValidationStatus::Unknown => "unknown".to_string(),
ValidationStatus::Invalid => "invalid".to_string(),
}),
validation_status: validation_status.map(|s| s.to_string()),
});
ops.push(RecordOp::Update {
collection: collection.clone(),
rkey: rkey.clone(),
collection: collection.to_string(),
rkey: rkey.to_string(),
cid: record_cid,
prev: prev_record_cid,
});
}
WriteOp::Delete { collection, rkey } => {
let collection_nsid = match collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(),
};
let key = format!("{}/{}", collection_nsid, rkey);
let key = format!("{}/{}", collection, rkey);
modified_keys.push(key.clone());
let prev_record_cid = mst.get(&key).await.ok().flatten();
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(),
Err(_) => {
return ApiError::InternalError(Some("Failed to delete from MST".into()))
.into_response()
}
};
results.push(WriteResult::DeleteResult {});
ops.push(RecordOp::Delete {
collection: collection.clone(),
rkey: rkey.clone(),
collection: collection.to_string(),
rkey: rkey.to_string(),
prev: prev_record_cid,
});
}
@@ -480,13 +383,7 @@ pub async fn apply_writes(
}
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();
}
Err(_) => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
};
let mut relevant_blocks = std::collections::BTreeMap::new();
for key in &modified_keys {
@@ -495,14 +392,16 @@ pub async fn apply_writes(
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
.into_response();
}
if original_mst
.blocks_for_path(key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
.into_response();
}
}
let mut written_cids = tracking_store.get_all_relevant_cids();
@@ -533,11 +432,7 @@ pub async fn apply_writes(
Ok(res) => res,
Err(e) => {
error!("Commit failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to commit changes"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to commit changes".into())).into_response();
}
};
+24 -62
View File
@@ -1,8 +1,10 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use crate::types::{AtIdentifier, Nsid, Rkey};
use axum::{
Json,
extract::State,
@@ -10,7 +12,6 @@ use axum::{
response::{IntoResponse, Response},
};
use cid::Cid;
use jacquard::types::string::Nsid;
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -20,9 +21,9 @@ use tracing::error;
#[derive(Deserialize)]
pub struct DeleteRecordInput {
pub repo: String,
pub collection: String,
pub rkey: String,
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Rkey,
#[serde(rename = "swapRecord")]
pub swap_record: Option<String>,
#[serde(rename = "swapCommit")]
@@ -68,14 +69,7 @@ pub async fn delete_record(
.await
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "AccountMigrated",
"message": "Account has been migrated. Repo operations are not allowed."
})),
)
.into_response();
return ApiError::AccountMigrated.into_response();
}
let did = auth.did;
@@ -86,50 +80,25 @@ pub async fn delete_record(
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
};
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
};
let key = format!("{}/{}", collection_nsid, input.rkey);
let key = format!("{}/{}", input.collection, input.rkey);
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
if expected_cid != actual_cid {
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response();
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
.into_response();
}
}
let prev_record_cid = mst.get(&key).await.ok().flatten();
@@ -140,25 +109,22 @@ pub async fn delete_record(
Ok(m) => m,
Err(e) => {
error!("Failed to delete from MST: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": format!("Failed to delete from MST: {:?}", e)}))).into_response();
return ApiError::InternalError(Some(format!("Failed to delete from MST: {:?}", e)))
.into_response();
}
};
let new_mst_root = match new_mst.persist().await {
Ok(c) => c,
Err(e) => {
error!("Failed to persist MST: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response();
}
};
let collection_for_audit = input.collection.clone();
let rkey_for_audit = input.rkey.clone();
let collection_for_audit = input.collection.to_string();
let rkey_for_audit = input.rkey.to_string();
let op = RecordOp::Delete {
collection: input.collection,
rkey: input.rkey,
collection: input.collection.to_string(),
rkey: rkey_for_audit.clone(),
prev: prev_record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
@@ -167,14 +133,16 @@ pub async fn delete_record(
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
.into_response();
}
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
.into_response();
}
let mut written_cids = tracking_store.get_all_relevant_cids();
for cid in relevant_blocks.keys() {
@@ -202,13 +170,7 @@ pub async fn delete_record(
.await
{
Ok(res) => res,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
if let Some(ref controller) = controller_did {
+41 -84
View File
@@ -1,8 +1,10 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::{AtIdentifier, Nsid, Rkey};
use axum::{
Json,
extract::{Query, State},
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use base64::Engine;
@@ -46,28 +48,29 @@ fn ipld_to_json(ipld: Ipld) -> Value {
#[derive(Deserialize)]
pub struct GetRecordInput {
pub repo: String,
pub collection: String,
pub rkey: String,
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Rkey,
pub cid: Option<String>,
}
pub async fn get_record(
State(state): State<AppState>,
headers: HeaderMap,
_headers: HeaderMap,
Query(input): Query<GetRecordInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user_id_opt = if input.repo.starts_with("did:") {
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo)
let user_id_opt = if input.repo.is_did() {
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str())
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| r.id))
} else {
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
let repo_str = input.repo.as_str();
let handle = if !repo_str.contains('.') {
format!("{}.{}", repo_str, hostname)
} else {
input.repo.clone()
repo_str.to_string()
};
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
@@ -77,76 +80,45 @@ pub async fn get_record(
let user_id: uuid::Uuid = match user_id_opt {
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
)
.into_response();
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let record_row = sqlx::query!(
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3",
user_id,
input.collection,
input.rkey
input.collection.as_str(),
input.rkey.as_str()
)
.fetch_optional(&state.db)
.await;
let record_cid_str: String = match record_row {
Ok(Some(row)) => row.record_cid,
_ => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "RecordNotFound", "message": "Record not found"})),
)
.into_response();
return ApiError::RecordNotFound.into_response();
}
};
if let Some(expected_cid) = &input.cid
&& &record_cid_str != expected_cid
{
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})),
)
.into_response();
return ApiError::RecordNotFound.into_response();
}
let cid = match Cid::from_str(&record_cid_str) {
Ok(c) => c,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Invalid CID in DB"})),
)
.into_response();
}
let Ok(cid) = Cid::from_str(&record_cid_str) else {
return ApiError::InternalError(Some("Invalid CID in DB".into())).into_response();
};
let block = match state.block_store.get(&cid).await {
Ok(Some(b)) => b,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Record block not found"})),
)
.into_response();
return ApiError::InternalError(Some("Record block not found".into())).into_response();
}
};
let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) {
Ok(v) => v,
Err(e) => {
error!("Failed to deserialize record: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let value = ipld_to_json(ipld);
@@ -159,14 +131,14 @@ pub async fn get_record(
}
#[derive(Deserialize)]
pub struct ListRecordsInput {
pub repo: String,
pub collection: String,
pub repo: AtIdentifier,
pub collection: Nsid,
pub limit: Option<i32>,
pub cursor: Option<String>,
#[serde(rename = "rkeyStart")]
pub rkey_start: Option<String>,
pub rkey_start: Option<Rkey>,
#[serde(rename = "rkeyEnd")]
pub rkey_end: Option<String>,
pub rkey_end: Option<Rkey>,
pub reverse: Option<bool>,
}
#[derive(Serialize)]
@@ -181,16 +153,17 @@ pub async fn list_records(
Query(input): Query<ListRecordsInput>,
) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user_id_opt = if input.repo.starts_with("did:") {
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo)
let user_id_opt = if input.repo.is_did() {
sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str())
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| r.id))
} else {
let handle = if !input.repo.contains('.') {
format!("{}.{}", input.repo, hostname)
let repo_str = input.repo.as_str();
let handle = if !repo_str.contains('.') {
format!("{}.{}", repo_str, hostname)
} else {
input.repo.clone()
repo_str.to_string()
};
sqlx::query!("SELECT id FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
@@ -200,18 +173,10 @@ pub async fn list_records(
let user_id: uuid::Uuid = match user_id_opt {
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "RepoNotFound", "message": "Repo not found"})),
)
.into_response();
return ApiError::RepoNotFound(Some("Repo not found".into())).into_response();
}
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let limit = input.limit.unwrap_or(50).clamp(1, 100);
@@ -226,7 +191,7 @@ pub async fn list_records(
);
sqlx::query_as(&query)
.bind(user_id)
.bind(&input.collection)
.bind(input.collection.as_str())
.bind(cursor)
.bind(limit_i64)
.fetch_all(&state.db)
@@ -255,12 +220,12 @@ pub async fn list_records(
);
let mut query_builder = sqlx::query_as::<_, (String, String)>(&query)
.bind(user_id)
.bind(&input.collection);
.bind(input.collection.as_str());
if let Some(start) = &input.rkey_start {
query_builder = query_builder.bind(start);
query_builder = query_builder.bind(start.as_str());
}
if let Some(end) = &input.rkey_end {
query_builder = query_builder.bind(end);
query_builder = query_builder.bind(end.as_str());
}
query_builder.bind(limit_i64).fetch_all(&state.db).await
};
@@ -268,11 +233,7 @@ pub async fn list_records(
Ok(r) => r,
Err(e) => {
error!("Error listing records: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let last_rkey = rows.last().map(|(rkey, _)| rkey.clone());
@@ -288,11 +249,7 @@ pub async fn list_records(
Ok(b) => b,
Err(e) => {
error!("Error fetching blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let mut records = Vec::new();
+24 -63
View File
@@ -1,10 +1,6 @@
use crate::api::error::ApiError;
use crate::validation::{RecordValidator, ValidationError, ValidationStatus};
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use axum::response::Response;
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box<Response>> {
validate_record_with_rkey(record, collection, None)
@@ -42,62 +38,27 @@ fn validation_error_to_response(
}
fn validation_error_to_box_response(e: ValidationError) -> Box<Response> {
match e {
ValidationError::MissingType => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
use axum::response::IntoResponse;
let msg = match e {
ValidationError::MissingType => "Record must have a $type field".to_string(),
ValidationError::TypeMismatch { expected, actual } => {
format!(
"Record $type '{}' does not match collection '{}'",
actual, expected
)
.into_response(),
),
ValidationError::TypeMismatch { expected, actual } => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
)
.into_response(),
),
ValidationError::MissingField(field) => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
)
.into_response(),
),
ValidationError::InvalidField { path, message } => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
)
.into_response(),
),
ValidationError::InvalidDatetime { path } => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
)
.into_response(),
),
ValidationError::BannedContent { path } => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})),
)
.into_response(),
),
ValidationError::UnknownType(type_name) => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})),
)
.into_response(),
),
e => Box::new(
(
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
)
.into_response(),
),
}
}
ValidationError::MissingField(field) => format!("Missing required field: {}", field),
ValidationError::InvalidField { path, message } => {
format!("Invalid field '{}': {}", path, message)
}
ValidationError::InvalidDatetime { path } => {
format!("Invalid datetime format at '{}'", path)
}
ValidationError::BannedContent { path } => {
format!("Unacceptable slur in record at '{}'", path)
}
ValidationError::UnknownType(type_name) => format!("Lexicon not found: lex:{}", type_name),
e => e.to_string(),
};
Box::new(ApiError::InvalidRecord(msg).into_response())
}
+72 -239
View File
@@ -1,9 +1,10 @@
use super::validation::validate_record_with_status;
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use crate::validation::ValidationStatus;
use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use axum::{
Json,
extract::State,
@@ -11,10 +12,6 @@ use axum::{
response::{IntoResponse, Response},
};
use cid::Cid;
use jacquard::types::{
integer::LimitedU32,
string::{Nsid, Tid},
};
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -52,12 +49,12 @@ pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result<bool,
}
pub struct RepoWriteAuth {
pub did: String,
pub did: Did,
pub user_id: Uuid,
pub current_root_cid: Cid,
pub is_oauth: bool,
pub scope: Option<String>,
pub controller_did: Option<String>,
pub controller_did: Option<Did>,
}
pub async fn prepare_repo_write(
@@ -70,13 +67,7 @@ pub async fn prepare_repo_write(
let extracted = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response()
})?;
.ok_or_else(|| ApiError::AuthenticationRequired.into_response())?;
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let auth_user = crate::auth::validate_token_with_dpop(
&state.db,
@@ -90,11 +81,7 @@ pub async fn prepare_repo_write(
.await
.map_err(|e| {
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
let mut response = (
StatusCode::UNAUTHORIZED,
Json(json!({"error": e.to_string()})),
)
.into_response();
let mut response = ApiError::from(e).into_response();
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" };
let www_auth = format!(
@@ -113,24 +100,15 @@ pub async fn prepare_repo_write(
response
})?;
if repo_did != auth_user.did {
return Err((
StatusCode::FORBIDDEN,
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
)
.into_response());
return Err(
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
);
}
if crate::util::is_account_migrated(&state.db, &auth_user.did)
.await
.unwrap_or(false)
{
return Err((
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountMigrated",
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
})),
)
.into_response());
return Err(ApiError::AccountMigrated.into_response());
}
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
.await
@@ -139,33 +117,16 @@ pub async fn prepare_repo_write(
.await
.unwrap_or(false);
if !is_verified && !is_delegated {
return Err((
StatusCode::FORBIDDEN,
Json(json!({
"error": "AccountNotVerified",
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
})),
)
.into_response());
return Err(ApiError::AccountNotVerified.into_response());
}
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &auth_user.did)
.fetch_optional(&state.db)
.await
.map_err(|e| {
error!("DB error fetching user: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
})?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response()
})?;
.ok_or_else(|| ApiError::InternalError(Some("User not found".into())).into_response())?;
let root_cid_str: String = sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
@@ -174,41 +135,26 @@ pub async fn prepare_repo_write(
.await
.map_err(|e| {
error!("DB error fetching repo root: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
})?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response()
})?;
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})),
)
.into_response()
})?;
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?;
let current_root_cid = Cid::from_str(&root_cid_str)
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())).into_response())?;
Ok(RepoWriteAuth {
did: auth_user.did,
did: auth_user.did.clone(),
user_id,
current_root_cid,
is_oauth: auth_user.is_oauth,
scope: auth_user.scope,
controller_did: auth_user.controller_did,
controller_did: auth_user.controller_did.clone(),
})
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct CreateRecordInput {
pub repo: String,
pub collection: String,
pub rkey: Option<String>,
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Option<Rkey>,
pub validate: Option<bool>,
pub record: serde_json::Value,
#[serde(rename = "swapCommit")]
@@ -224,7 +170,7 @@ pub struct CommitInfo {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateRecordOutput {
pub uri: String,
pub uri: AtUri,
pub cid: String,
pub commit: CommitInfo,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -266,44 +212,18 @@ pub async fn create_record(
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
};
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
};
let validation_status = if input.validate == Some(false) {
None
} else {
@@ -311,59 +231,37 @@ pub async fn create_record(
match validate_record_with_status(
&input.record,
&input.collection,
input.rkey.as_deref(),
input.rkey.as_ref().map(|r| r.as_str()),
require_lexicon,
) {
Ok(status) => Some(status),
Err(err_response) => return *err_response,
}
};
let rkey = input
.rkey
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
let record_ipld = crate::util::json_to_ipld(&input.record);
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
)
.into_response();
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
}
let record_cid = match tracking_store.put(&record_bytes).await {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
}
};
let key = format!("{}/{}", collection_nsid, rkey);
let key = format!("{}/{}", input.collection, rkey);
let new_mst = match mst.add(&key, record_cid).await {
Ok(m) => m,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to add to MST".into())).into_response(),
};
let new_mst_root = match new_mst.persist().await {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(),
};
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: rkey.clone(),
collection: input.collection.to_string(),
rkey: rkey.to_string(),
cid: record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
@@ -372,14 +270,16 @@ pub async fn create_record(
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
.into_response();
}
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
.into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
let mut written_cids = tracking_store.get_all_relevant_cids();
@@ -409,13 +309,7 @@ pub async fn create_record(
.await
{
Ok(res) => res,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
if let Some(ref controller) = controller_did {
@@ -439,17 +333,13 @@ pub async fn create_record(
(
StatusCode::OK,
Json(CreateRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
uri: AtUri::from_parts(&did, &input.collection, &rkey),
cid: record_cid.to_string(),
commit: CommitInfo {
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
},
validation_status: validation_status.map(|s| match s {
ValidationStatus::Valid => "valid".to_string(),
ValidationStatus::Unknown => "unknown".to_string(),
ValidationStatus::Invalid => "invalid".to_string(),
}),
validation_status: validation_status.map(|s| s.to_string()),
}),
)
.into_response()
@@ -457,9 +347,9 @@ pub async fn create_record(
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct PutRecordInput {
pub repo: String,
pub collection: String,
pub rkey: String,
pub repo: AtIdentifier,
pub collection: Nsid,
pub rkey: Rkey,
pub validate: Option<bool>,
pub record: serde_json::Value,
#[serde(rename = "swapCommit")]
@@ -470,7 +360,7 @@ pub struct PutRecordInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PutRecordOutput {
pub uri: String,
pub uri: AtUri,
pub cid: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<CommitInfo>,
@@ -521,45 +411,19 @@ pub async fn put_record(
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid)
{
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response();
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(),
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
_ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(),
};
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
};
let key = format!("{}/{}", collection_nsid, input.rkey);
let key = format!("{}/{}", input.collection, input.rkey);
let validation_status = if input.validate == Some(false) {
None
} else {
@@ -567,7 +431,7 @@ pub async fn put_record(
match validate_record_with_status(
&input.record,
&input.collection,
Some(&input.rkey),
Some(input.rkey.as_str()),
require_lexicon,
) {
Ok(status) => Some(status),
@@ -578,41 +442,30 @@ pub async fn put_record(
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
if expected_cid != actual_cid {
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response();
return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into()))
.into_response();
}
}
let existing_cid = mst.get(&key).await.ok().flatten();
let record_ipld = crate::util::json_to_ipld(&input.record);
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
)
.into_response();
return ApiError::InvalidRecord("Failed to serialize record".into()).into_response();
}
let record_cid = match tracking_store.put(&record_bytes).await {
Ok(c) => c,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to save record block".into())).into_response()
}
};
if existing_cid == Some(record_cid) {
return (
StatusCode::OK,
Json(PutRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
cid: record_cid.to_string(),
commit: None,
validation_status: validation_status.map(|s| match s {
ValidationStatus::Valid => "valid".to_string(),
ValidationStatus::Unknown => "unknown".to_string(),
ValidationStatus::Invalid => "invalid".to_string(),
}),
validation_status: validation_status.map(|s| s.to_string()),
}),
)
.into_response();
@@ -621,46 +474,34 @@ pub async fn put_record(
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();
return ApiError::InternalError(Some("Failed to update MST".into())).into_response()
}
}
} else {
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();
return ApiError::InternalError(Some("Failed to add to MST".into())).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();
return ApiError::InternalError(Some("Failed to persist MST".into())).into_response()
}
};
let op = if existing_cid.is_some() {
RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
collection: input.collection.to_string(),
rkey: input.rkey.to_string(),
cid: record_cid,
prev: existing_cid,
}
} else {
RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
collection: input.collection.to_string(),
rkey: input.rkey.to_string(),
cid: record_cid,
}
};
@@ -670,14 +511,16 @@ pub async fn put_record(
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get new MST blocks for path".into()))
.into_response();
}
if mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
return ApiError::InternalError(Some("Failed to get old MST blocks for path".into()))
.into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
let mut written_cids = tracking_store.get_all_relevant_cids();
@@ -708,13 +551,7 @@ pub async fn put_record(
.await
{
Ok(res) => res,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
}
Err(e) => return ApiError::InternalError(Some(e)).into_response(),
};
if let Some(ref controller) = controller_did {
@@ -738,17 +575,13 @@ pub async fn put_record(
(
StatusCode::OK,
Json(PutRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
cid: record_cid.to_string(),
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
}),
validation_status: validation_status.map(|s| match s {
ValidationStatus::Valid => "valid".to_string(),
ValidationStatus::Unknown => "unknown".to_string(),
ValidationStatus::Invalid => "invalid".to_string(),
}),
validation_status: validation_status.map(|s| s.to_string()),
}),
)
.into_response()
+114
View File
@@ -0,0 +1,114 @@
use crate::types::Did;
use axum::{Json, response::IntoResponse};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct EmptyResponse {}
impl EmptyResponse {
pub fn ok() -> impl IntoResponse {
Json(Self {})
}
}
#[derive(Debug, Serialize)]
pub struct SuccessResponse {
pub success: bool,
}
impl SuccessResponse {
pub fn ok() -> impl IntoResponse {
Json(Self { success: true })
}
}
#[derive(Debug, Serialize)]
pub struct DidResponse {
pub did: Did,
}
impl DidResponse {
pub fn new(did: impl Into<Did>) -> impl IntoResponse {
Json(Self { did: did.into() })
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenRequiredResponse {
pub token_required: bool,
}
impl TokenRequiredResponse {
pub fn new(required: bool) -> impl IntoResponse {
Json(Self { token_required: required })
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HasPasswordResponse {
pub has_password: bool,
}
impl HasPasswordResponse {
pub fn new(has_password: bool) -> impl IntoResponse {
Json(Self { has_password })
}
}
#[derive(Debug, Serialize)]
pub struct VerifiedResponse {
pub verified: bool,
}
impl VerifiedResponse {
pub fn new(verified: bool) -> impl IntoResponse {
Json(Self { verified })
}
}
#[derive(Debug, Serialize)]
pub struct EnabledResponse {
pub enabled: bool,
}
impl EnabledResponse {
pub fn new(enabled: bool) -> impl IntoResponse {
Json(Self { enabled })
}
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub status: String,
}
impl StatusResponse {
pub fn new(status: impl Into<String>) -> impl IntoResponse {
Json(Self { status: status.into() })
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DidDocumentResponse {
pub did_document: serde_json::Value,
}
impl DidDocumentResponse {
pub fn new(did_document: serde_json::Value) -> impl IntoResponse {
Json(Self { did_document })
}
}
#[derive(Debug, Serialize)]
pub struct OptionsResponse<T: Serialize> {
pub options: T,
}
impl<T: Serialize> OptionsResponse<T> {
pub fn new(options: T) -> Json<Self> {
Json(Self { options })
}
}
+67 -185
View File
@@ -1,7 +1,9 @@
use crate::api::ApiError;
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::cache::Cache;
use crate::plc::PlcClient;
use crate::state::AppState;
use crate::types::PlainPassword;
use axum::{
Json,
extract::State,
@@ -15,7 +17,6 @@ use jacquard_repo::commit::Commit;
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::{error, info, warn};
@@ -64,20 +65,16 @@ pub async fn check_account_status(
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did)
let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await;
let deactivated_at = match user_status {
@@ -142,7 +139,7 @@ pub async fn check_account_status(
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let valid_did = is_valid_did_for_service(&state.db, &state.cache, &did).await;
let valid_did = is_valid_did_for_service(&state.db, state.cache.clone(), did.as_str()).await;
(
StatusCode::OK,
Json(CheckAccountStatusOutput {
@@ -160,7 +157,7 @@ pub async fn check_account_status(
.into_response()
}
async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc<dyn Cache>, did: &str) -> bool {
async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: Arc<dyn Cache>, did: &str) -> bool {
assert_valid_did_document_for_service(db, cache, did, false)
.await
.is_ok()
@@ -168,10 +165,10 @@ async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc<dyn Cache>, did
async fn assert_valid_did_document_for_service(
db: &sqlx::PgPool,
cache: &Arc<dyn Cache>,
cache: Arc<dyn Cache>,
did: &str,
with_retry: bool,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
) -> Result<(), ApiError> {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let expected_endpoint = format!("https://{}", hostname);
@@ -228,17 +225,10 @@ async fn assert_valid_did_document_for_service(
}
}
let doc_data = match doc_data {
Some(d) => d,
None => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": last_error.unwrap_or_else(|| "DID document validation failed".to_string())
})),
));
}
let Some(doc_data) = doc_data else {
return Err(ApiError::InvalidRequest(
last_error.unwrap_or_else(|| "DID document validation failed".to_string()),
));
};
let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok();
@@ -249,12 +239,8 @@ async fn assert_valid_did_document_for_service(
.map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "Server rotation key not included in PLC DID data"
})),
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
}
}
@@ -272,27 +258,18 @@ async fn assert_valid_did_document_for_service(
.await
.map_err(|e| {
error!("Failed to fetch user key: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
ApiError::InternalError(None)
})?;
if let Some(row) = user_row {
let key_bytes = crate::config::decrypt_key(&row.key_bytes, row.encryption_version)
.map_err(|e| {
error!("Failed to decrypt user key: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
ApiError::InternalError(None)
})?;
let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
error!("Failed to create signing key: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
ApiError::InternalError(None)
})?;
let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key);
@@ -301,12 +278,8 @@ async fn assert_valid_did_document_for_service(
"DID {} has signing key {:?}, expected {}",
did, doc_signing_key, expected_did_key
);
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "DID document verification method does not match expected signing key"
})),
return Err(ApiError::InvalidRequest(
"DID document verification method does not match expected signing key".into(),
));
}
}
@@ -333,23 +306,11 @@ async fn assert_valid_did_document_for_service(
};
let resp = client.get(&url).send().await.map_err(|e| {
warn!("Failed to fetch did:web document for {}: {:?}", did, e);
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Could not resolve DID document: {}", e)
})),
)
ApiError::InvalidRequest(format!("Could not resolve DID document: {}", e))
})?;
let doc: serde_json::Value = resp.json().await.map_err(|e| {
warn!("Failed to parse did:web document for {}: {:?}", did, e);
(
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Could not parse DID document: {}", e)
})),
)
ApiError::InvalidRequest(format!("Could not parse DID document: {}", e))
})?;
let pds_endpoint = doc
@@ -370,12 +331,8 @@ async fn assert_valid_did_document_for_service(
"DID {} has endpoint {:?}, expected {}",
did, pds_endpoint, expected_endpoint
);
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "DID document atproto_pds service endpoint does not match PDS public url"
})),
return Err(ApiError::InvalidRequest(
"DID document atproto_pds service endpoint does not match PDS public url".into(),
));
}
}
@@ -441,15 +398,15 @@ pub async fn activate_account(
did
);
let did_validation_start = std::time::Instant::now();
if let Err((status, json)) =
assert_valid_did_document_for_service(&state.db, &state.cache, &did, true).await
if let Err(e) =
assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true).await
{
info!(
"[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})",
did,
did_validation_start.elapsed()
);
return (status, json).into_response();
return e.into_response();
}
info!(
"[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})",
@@ -457,7 +414,7 @@ pub async fn activate_account(
did_validation_start.elapsed()
);
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
.ok()
@@ -466,7 +423,7 @@ pub async fn activate_account(
"[MIGRATION] activateAccount: Activating account did={} handle={:?}",
did, handle
);
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did.as_str())
.execute(&state.db)
.await;
match result {
@@ -483,7 +440,7 @@ pub async fn activate_account(
did
);
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None).await
{
warn!(
"[MIGRATION] activateAccount: Failed to sequence account activation event: {}",
@@ -497,7 +454,7 @@ pub async fn activate_account(
did, handle
);
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, handle.as_deref())
crate::api::repo::record::sequence_identity_event(&state, did.as_str(), handle.as_deref())
.await
{
warn!(
@@ -509,7 +466,7 @@ pub async fn activate_account(
}
let repo_root = sqlx::query_scalar!(
"SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
did
did.as_str()
)
.fetch_optional(&state.db)
.await
@@ -531,7 +488,7 @@ pub async fn activate_account(
};
if let Err(e) = crate::api::repo::record::sequence_sync_event(
&state,
&did,
did.as_str(),
&root_cid,
rev.as_deref(),
)
@@ -551,18 +508,14 @@ pub async fn activate_account(
);
}
info!("[MIGRATION] activateAccount: SUCCESS for did={}", did);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!(
"[MIGRATION] activateAccount: DB error activating account: {:?}",
e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -621,7 +574,7 @@ pub async fn deactivate_account(
let did = auth_user.did;
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
.ok()
@@ -629,7 +582,7 @@ pub async fn deactivate_account(
let result = sqlx::query!(
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
did,
did.as_str(),
delete_after
)
.execute(&state.db)
@@ -642,7 +595,7 @@ pub async fn deactivate_account(
}
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
&did,
did.as_str(),
false,
Some("deactivated"),
)
@@ -650,15 +603,11 @@ pub async fn deactivate_account(
{
warn!("Failed to sequence account deactivated event: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error deactivating account: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -694,21 +643,17 @@ pub async fn request_account_delete(
};
let did = validated.did.clone();
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await;
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await {
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()).await;
}
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str())
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let confirmation_token = Uuid::new_v4().to_string();
@@ -716,18 +661,14 @@ pub async fn request_account_delete(
let insert = sqlx::query!(
"INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
confirmation_token,
did,
did.as_str(),
expires_at
)
.execute(&state.db)
.await;
if let Err(e) = insert {
error!("DB error creating deletion token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
@@ -737,13 +678,13 @@ pub async fn request_account_delete(
warn!("Failed to enqueue account deletion notification: {:?}", e);
}
info!("Account deletion requested for user {}", did);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
pub struct DeleteAccountInput {
pub did: String,
pub password: String,
pub did: crate::types::Did,
pub password: PlainPassword,
pub token: String,
}
@@ -751,60 +692,33 @@ pub async fn delete_account(
State(state): State<AppState>,
Json(input): Json<DeleteAccountInput>,
) -> Response {
let did = input.did.trim();
let did = &input.did;
let password = &input.password;
let token = input.token.trim();
if did.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
)
.into_response();
}
if password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "password is required"})),
)
.into_response();
return ApiError::InvalidRequest("password is required".into()).into_response();
}
const OLD_PASSWORD_MAX_LENGTH: usize = 512;
if password.len() > OLD_PASSWORD_MAX_LENGTH {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid password length."})),
)
.into_response();
return ApiError::InvalidRequest("Invalid password length".into()).into_response();
}
if token.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "token is required"})),
)
.into_response();
return ApiError::InvalidToken(Some("token is required".into())).into_response();
}
let user = sqlx::query!(
"SELECT id, password_hash, handle FROM users WHERE did = $1",
did
did.as_str()
)
.fetch_optional(&state.db)
.await;
let (user_id, password_hash, handle) = match user {
Ok(Some(row)) => (row.id, row.password_hash, row.handle),
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::InvalidRequest("account not found".into()).into_response();
}
Err(e) => {
error!("DB error in delete_account: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let password_valid = if password_hash
@@ -826,11 +740,7 @@ pub async fn delete_account(
.any(|row| verify(password, &row.password_hash).unwrap_or(false))
};
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid password"})),
)
.into_response();
return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response();
}
let deletion_request = sqlx::query!(
"SELECT did, expires_at FROM account_deletion_requests WHERE token = $1",
@@ -841,27 +751,15 @@ pub async fn delete_account(
let (token_did, expires_at) = match deletion_request {
Ok(Some(row)) => (row.did, row.expires_at),
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
)
.into_response();
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
}
Err(e) => {
error!("DB error fetching deletion token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if token_did != did {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Token does not match account"})),
)
.into_response();
if token_did != did.as_str() {
return ApiError::InvalidToken(Some("Token does not match account".into())).into_response();
}
if Utc::now() > expires_at {
let _ = sqlx::query!(
@@ -870,21 +768,13 @@ pub async fn delete_account(
)
.execute(&state.db)
.await;
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
return ApiError::ExpiredToken(None).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();
return ApiError::InternalError(None).into_response();
}
};
let deletion_result: Result<(), sqlx::Error> = async {
@@ -919,11 +809,7 @@ pub async fn delete_account(
Ok(()) => {
if let Err(e) = tx.commit().await {
error!("Failed to commit account deletion transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let account_seq = crate::api::repo::record::sequence_account_event(
&state,
@@ -957,15 +843,11 @@ pub async fn delete_account(
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
info!("Account {} deleted successfully", did);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Err(e) => {
error!("DB error deleting account, rolling back: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+15 -21
View File
@@ -1,4 +1,5 @@
use crate::api::ApiError;
use crate::api::error::ApiError;
use crate::api::EmptyResponse;
use crate::auth::BearerAuth;
use crate::delegation::{self, DelegationActionType};
use crate::state::{AppState, RateLimitKind};
@@ -60,7 +61,7 @@ pub async fn list_app_passwords(
}
Err(e) => {
error!("DB error listing app passwords: {:?}", e);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -95,14 +96,7 @@ pub async fn create_app_password(
.await
{
warn!(ip = %client_ip, "App password creation rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
Ok(id) => id,
@@ -134,7 +128,7 @@ pub async fn create_app_password(
let intersected = delegation::intersect_scopes(requested, &granted_scopes);
if intersected.is_empty() && !granted_scopes.is_empty() {
return ApiError::InsufficientScope.into_response();
return ApiError::InsufficientScope(None).into_response();
}
let scope_result = if intersected.is_empty() {
@@ -167,11 +161,11 @@ pub async fn create_app_password(
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let privileged = input.privileged.unwrap_or(false);
@@ -184,7 +178,7 @@ pub async fn create_app_password(
created_at,
privileged,
final_scopes,
controller_did
controller_did.as_deref()
)
.execute(&state.db)
.await
@@ -218,7 +212,7 @@ pub async fn create_app_password(
}
Err(e) => {
error!("DB error creating app password: {:?}", e);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -243,7 +237,7 @@ pub async fn revoke_app_password(
}
let sessions_to_invalidate = sqlx::query_scalar!(
"SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2",
auth_user.did,
&auth_user.did,
name
)
.fetch_all(&state.db)
@@ -251,17 +245,17 @@ pub async fn revoke_app_password(
.unwrap_or_default();
if let Err(e) = sqlx::query!(
"DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2",
auth_user.did,
&auth_user.did,
name
)
.execute(&state.db)
.await
{
error!("DB error revoking sessions for app password: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
for jti in &sessions_to_invalidate {
let cache_key = format!("auth:session:{}:{}", auth_user.did, jti);
let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti);
let _ = state.cache.delete(&cache_key).await;
}
if let Err(e) = sqlx::query!(
@@ -273,7 +267,7 @@ pub async fn revoke_app_password(
.await
{
error!("DB error revoking app password: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
Json(json!({})).into_response()
EmptyResponse::ok().into_response()
}
+48 -195
View File
@@ -1,10 +1,10 @@
use crate::api::ApiError;
use crate::api::error::ApiError;
use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse};
use crate::auth::BearerAuth;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
@@ -22,14 +22,7 @@ pub async fn request_email_update(
.await
{
warn!(ip = %client_ip, "Email update rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
@@ -41,7 +34,7 @@ pub async fn request_email_update(
return e;
}
let did = auth.0.did.clone();
let did = auth.0.did.to_string();
let user = match sqlx::query!(
"SELECT id, handle, email, email_verified FROM users WHERE did = $1",
did
@@ -51,31 +44,17 @@ pub async fn request_email_update(
{
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let current_email: String = match user.email {
Some(e) => e,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})),
)
.into_response();
}
let Some(current_email) = user.email else {
return ApiError::InvalidRequest("account does not have an email address".into())
.into_response();
};
let token_required = user.email_verified;
@@ -98,11 +77,7 @@ pub async fn request_email_update(
}
info!("Email update requested for user {}", user.id);
(
StatusCode::OK,
Json(json!({ "tokenRequired": token_required })),
)
.into_response()
TokenRequiredResponse::new(token_required).into_response()
}
#[derive(Deserialize)]
@@ -124,14 +99,7 @@ pub async fn confirm_email(
.await
{
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
if let Err(e) = crate::auth::scope_check::check_account_scope(
@@ -143,7 +111,7 @@ pub async fn confirm_email(
return e;
}
let did = auth.0.did;
let did = auth.0.did.to_string();
let user = match sqlx::query!(
"SELECT id, email, email_verified FROM users WHERE did = $1",
did
@@ -153,44 +121,26 @@ pub async fn confirm_email(
{
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountNotFound", "message": "user not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let current_email = match &user.email {
Some(e) => e.to_lowercase(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})),
)
.into_response();
}
let Some(ref email) = user.email else {
return ApiError::InvalidEmail.into_response();
};
let current_email = email.to_lowercase();
let provided_email = input.email.trim().to_lowercase();
if provided_email != current_email {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "invalid email"})),
)
.into_response();
return ApiError::InvalidEmail.into_response();
}
if user.email_verified {
return (StatusCode::OK, Json(json!({}))).into_response();
return EmptyResponse::ok().into_response();
}
let confirmation_code =
@@ -205,28 +155,14 @@ pub async fn confirm_email(
match verified {
Ok(token_data) => {
if token_data.did != did {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
}
@@ -239,15 +175,11 @@ pub async fn confirm_email(
if let Err(e) = update {
error!("DB error confirming email: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
info!("Email confirmed for user {}", user.id);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
@@ -264,17 +196,10 @@ pub async fn update_email(
headers: axum::http::HeaderMap,
Json(input): Json<UpdateEmailInput>,
) -> Response {
let bearer_token = match crate::auth::extract_bearer_token_from_header(
let Some(bearer_token) = 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();
}
) else {
return ApiError::AuthenticationRequired.into_response();
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await;
@@ -292,7 +217,7 @@ pub async fn update_email(
return e;
}
let did = auth_user.did;
let did = auth_user.did.to_string();
let user = match sqlx::query!(
"SELECT id, email, email_verified FROM users WHERE did = $1",
did
@@ -302,19 +227,11 @@ pub async fn update_email(
{
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -324,36 +241,23 @@ pub async fn update_email(
let new_email = input.email.trim().to_lowercase();
if !crate::api::validation::is_valid_email(&new_email) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "This email address is not supported, please use a different email."
})),
return ApiError::InvalidRequest(
"This email address is not supported, please use a different email.".into(),
)
.into_response();
.into_response();
}
if let Some(ref current) = current_email
&& new_email == current.to_lowercase()
{
return (StatusCode::OK, Json(json!({}))).into_response();
return EmptyResponse::ok().into_response();
}
if email_verified {
let confirmation_token = match &input.token {
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TokenRequired",
"message": "confirmation token required"
})),
)
.into_response();
}
let Some(ref t) = input.token else {
return ApiError::TokenRequired.into_response();
};
let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim());
let current_email_lower = current_email
.as_ref()
@@ -369,28 +273,14 @@ pub async fn update_email(
match verified {
Ok(token_data) => {
if token_data.did != did {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
return ApiError::ExpiredToken(None).into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
}
}
@@ -404,14 +294,7 @@ pub async fn update_email(
.await;
if let Ok(Some(_)) = exists {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "This email address is already in use, please use a different email."
})),
)
.into_response();
return ApiError::InvalidRequest("Email is already in use".into()).into_response();
}
let update: Result<sqlx::postgres::PgQueryResult, sqlx::Error> = sqlx::query!(
@@ -428,20 +311,9 @@ pub async fn update_email(
.map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "This email address is already in use, please use a different email."
})),
)
.into_response();
return ApiError::EmailTaken.into_response();
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let verification_token =
@@ -474,7 +346,7 @@ pub async fn update_email(
}
info!("Email updated for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
@@ -492,14 +364,7 @@ pub async fn check_email_verified(
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let user = sqlx::query!(
@@ -510,23 +375,11 @@ pub async fn check_email_verified(
.await;
match user {
Ok(Some(row)) => (
StatusCode::OK,
Json(json!({ "verified": row.email_verified })),
)
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "AccountNotFound", "message": "Account not found" })),
)
.into_response(),
Ok(Some(row)) => VerifiedResponse::new(row.email_verified).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error checking email verified: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError" })),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+9 -9
View File
@@ -53,7 +53,7 @@ pub async fn create_invite_code(
return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response();
}
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.clone());
let for_account = input.for_account.unwrap_or_else(|| auth_user.did.to_string());
let code = gen_invite_code();
match sqlx::query!(
@@ -69,13 +69,13 @@ pub async fn create_invite_code(
Ok(result) => {
if result.rows_affected() == 0 {
error!("No admin user found to create invite code");
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
Json(CreateInviteCodeOutput { code }).into_response()
}
Err(e) => {
error!("DB error creating invite code: {:?}", e);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -112,7 +112,7 @@ pub async fn create_invite_codes(
let for_accounts = input
.for_accounts
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![auth_user.did.clone()]);
.unwrap_or_else(|| vec![auth_user.did.to_string()]);
let admin_user_id =
match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1")
@@ -122,11 +122,11 @@ pub async fn create_invite_codes(
Ok(Some(id)) => id,
Ok(None) => {
error!("No admin user found to create invite codes");
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("DB error looking up admin user: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -147,7 +147,7 @@ pub async fn create_invite_codes(
.await
{
error!("DB error creating invite code: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
codes.push(code);
}
@@ -213,7 +213,7 @@ pub async fn get_account_invite_codes(
WHERE ic.for_account = $1
ORDER BY ic.created_at DESC
"#,
auth_user.did
&auth_user.did
)
.fetch_all(&state.db)
.await
@@ -221,7 +221,7 @@ pub async fn get_account_invite_codes(
Ok(rows) => rows,
Err(e) => {
error!("DB error fetching invite codes: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
+12 -20
View File
@@ -66,19 +66,15 @@ pub async fn update_did_document(
};
if !auth_user.did.starts_with("did:web:") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "DID document updates are only available for did:web accounts"
})),
return ApiError::InvalidRequest(
"DID document updates are only available for did:web accounts".into(),
)
.into_response();
.into_response();
}
let user = match sqlx::query!(
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
auth_user.did
&auth_user.did
)
.fetch_optional(&state.db)
.await
@@ -87,7 +83,7 @@ pub async fn update_did_document(
Ok(None) => return ApiError::AccountNotFound.into_response(),
Err(e) => {
tracing::error!("DB error getting user: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -171,7 +167,7 @@ pub async fn update_did_document(
if let Err(e) = upsert_result {
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(ref endpoint) = input.service_endpoint {
@@ -180,20 +176,20 @@ pub async fn update_did_document(
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
endpoint_clean,
now,
auth_user.did
&auth_user.did
)
.execute(&state.db)
.await;
if let Err(e) = update_result {
tracing::error!("DB error updating service endpoint: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
}
let did_doc = build_did_document(&state.db, &auth_user.did).await;
tracing::info!("Updated DID document for {}", auth_user.did);
tracing::info!("Updated DID document for {}", &auth_user.did);
(
StatusCode::OK,
@@ -236,14 +232,10 @@ pub async fn get_did_document(
};
if !auth_user.did.starts_with("did:web:") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "This endpoint is only available for did:web accounts"
})),
return ApiError::InvalidRequest(
"This endpoint is only available for did:web accounts".into(),
)
.into_response();
.into_response();
}
let did_doc = build_did_document(&state.db, &auth_user.did).await;
+120 -397
View File
@@ -1,7 +1,9 @@
use crate::api::SuccessResponse;
use crate::api::error::ApiError;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash};
@@ -18,6 +20,7 @@ use uuid::Uuid;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle, PlainPassword};
use crate::validation::validate_password;
fn extract_client_ip(headers: &HeaderMap) -> String {
@@ -80,8 +83,8 @@ pub struct CreatePasskeyAccountInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePasskeyAccountResponse {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
pub setup_token: String,
pub setup_expires_at: chrono::DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -99,14 +102,8 @@ pub async fn create_passkey_account(
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many account creation attempts. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),))
.into_response();
}
let byod_auth = if let Some(token) =
@@ -127,14 +124,11 @@ pub async fn create_passkey_account(
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": format!("Service token verification failed: {}", e)
})),
)
.into_response();
return ApiError::AuthenticationFailed(Some(format!(
"Service token verification failed: {}",
e
)))
.into_response();
}
}
} else {
@@ -165,12 +159,8 @@ pub async fn create_passkey_account(
};
match crate::api::validation::validate_short_handle(handle_to_validate) {
Ok(h) => format!("{}.{}", h, hostname),
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
)
.into_response();
Err(_) => {
return ApiError::InvalidHandle(None).into_response();
}
}
} else {
@@ -185,11 +175,7 @@ pub async fn create_passkey_account(
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
return ApiError::InvalidEmail.into_response();
}
if let Some(ref code) = input.invite_code {
@@ -204,22 +190,14 @@ pub async fn create_passkey_account(
.unwrap_or(Some(false));
if valid != Some(true) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
)
.into_response();
return ApiError::InvalidInviteCode.into_response();
}
} else {
let invite_required = std::env::var("INVITE_CODE_REQUIRED")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if invite_required {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
)
.into_response();
return ApiError::InviteCodeRequired.into_response();
}
}
@@ -227,36 +205,21 @@ pub async fn create_passkey_account(
let verification_recipient = match verification_channel {
"email" => match &email {
Some(e) if !e.is_empty() => e.clone(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
).into_response(),
_ => return ApiError::MissingEmail.into_response(),
},
"discord" => match &input.discord_id {
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
).into_response(),
_ => return ApiError::MissingDiscordId.into_response(),
},
"telegram" => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
).into_response(),
_ => return ApiError::MissingTelegramUsername.into_response(),
},
"signal" => match &input.signal_number {
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
).into_response(),
_ => return ApiError::MissingSignalNumber.into_response(),
},
_ => return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
).into_response(),
_ => return ApiError::InvalidVerificationChannel.into_response(),
};
use k256::ecdsa::SigningKey;
@@ -283,22 +246,11 @@ pub async fn create_passkey_account(
match reserved {
Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidSigningKey",
"message": "Signing key not found, already used, or expired"
})),
)
.into_response();
return ApiError::InvalidSigningKey.into_response();
}
Err(e) => {
error!("Error looking up reserved signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
} else {
@@ -310,11 +262,7 @@ pub async fn create_passkey_account(
Ok(k) => k,
Err(e) => {
error!("Error creating signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -330,34 +278,25 @@ pub async fn create_passkey_account(
let d = match &input.did {
Some(d) if !d.trim().is_empty() => d.trim(),
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
return ApiError::InvalidRequest(
"External did:web requires the 'did' field to be provided".into(),
)
.into_response();
.into_response();
}
};
if !d.starts_with("did:web:") {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
),
)
return ApiError::InvalidDid("External DID must be a did:web".into())
.into_response();
}
if is_byod_did_web {
if let Some(ref auth_did) = byod_auth
&& d != auth_did
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, d)
})),
)
.into_response();
return ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, d
))
.into_response();
}
info!(did = %d, "Creating external did:web passkey account (BYOD key)");
} else {
@@ -369,11 +308,7 @@ pub async fn create_passkey_account(
)
.await
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
return ApiError::InvalidDid(e).into_response();
}
info!(did = %d, "Creating external did:web passkey account (reserved key)");
}
@@ -384,36 +319,25 @@ pub async fn create_passkey_account(
if let Some(ref provided_did) = input.did {
if provided_did.starts_with("did:plc:") {
if provided_did != auth_did {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "AuthorizationError",
"message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did)
})),
)
.into_response();
return ApiError::AuthorizationError(format!(
"Service token issuer {} does not match DID {}",
auth_did, provided_did
))
.into_response();
}
info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)");
provided_did.clone()
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "BYOD migration requires a did:plc or did:web DID"
})),
return ApiError::InvalidRequest(
"BYOD migration requires a did:plc or did:web DID".into(),
)
.into_response();
.into_response();
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "BYOD migration requires the 'did' field"
})),
return ApiError::InvalidRequest(
"BYOD migration requires the 'did' field".into(),
)
.into_response();
.into_response();
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
@@ -428,10 +352,7 @@ pub async fn create_passkey_account(
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
return ApiError::InternalError(Some("Failed to create PLC operation".into()))
.into_response();
}
};
@@ -442,14 +363,11 @@ pub async fn create_passkey_account(
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
return ApiError::UpstreamErrorMsg(format!(
"Failed to register DID with PLC directory: {}",
e
))
.into_response();
}
genesis_result.did
}
@@ -463,11 +381,7 @@ pub async fn create_passkey_account(
Ok(h) => h,
Err(e) => {
error!("Error hashing setup token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let setup_expires_at = Utc::now() + Duration::hours(1);
@@ -476,11 +390,7 @@ pub async fn create_passkey_account(
Ok(tx) => tx,
Err(e) => {
error!("Error starting transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -545,27 +455,13 @@ pub async fn create_passkey_account(
{
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
)
.into_response();
return ApiError::HandleNotAvailable(None).into_response();
} else if constraint.contains("email") {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidEmail", "message": "Email already registered"}),
),
)
.into_response();
return ApiError::EmailTaken.into_response();
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -573,11 +469,7 @@ pub async fn create_passkey_account(
Ok(bytes) => bytes,
Err(e) => {
error!("Error encrypting signing key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -591,11 +483,7 @@ pub async fn create_passkey_account(
.await
{
error!("Error inserting user key: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(key_id) = reserved_key_id
@@ -607,11 +495,7 @@ pub async fn create_passkey_account(
.await
{
error!("Error marking reserved key as used: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let mst = Mst::new(Arc::new(state.block_store.clone()));
@@ -619,11 +503,7 @@ pub async fn create_passkey_account(
Ok(c) => c,
Err(e) => {
error!("Error persisting MST: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let rev = Tid::now(LimitedU32::MIN);
@@ -632,22 +512,14 @@ pub async fn create_passkey_account(
Ok(result) => result,
Err(e) => {
error!("Error creating genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
Ok(c) => c,
Err(e) => {
error!("Error saving genesis commit: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let commit_cid_str = commit_cid.to_string();
@@ -662,11 +534,7 @@ pub async fn create_passkey_account(
.await
{
error!("Error inserting repo: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
if let Err(e) = sqlx::query!(
@@ -682,11 +550,7 @@ pub async fn create_passkey_account(
.await
{
error!("Error inserting user_blocks: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Some(ref code) = input.invite_code {
@@ -727,11 +591,7 @@ pub async fn create_passkey_account(
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if !is_byod_did_web {
@@ -819,8 +679,8 @@ pub async fn create_passkey_account(
};
Json(CreatePasskeyAccountResponse {
did,
handle,
did: did.into(),
handle: handle.into(),
setup_token,
setup_expires_at,
access_jwt,
@@ -831,7 +691,7 @@ pub async fn create_passkey_account(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletePasskeySetupInput {
pub did: String,
pub did: Did,
pub setup_token: String,
pub passkey_credential: serde_json::Value,
pub passkey_friendly_name: Option<String>,
@@ -840,8 +700,8 @@ pub struct CompletePasskeySetupInput {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletePasskeySetupResponse {
pub did: String,
pub handle: String,
pub did: Did,
pub handle: Handle,
pub app_password: String,
pub app_password_name: String,
}
@@ -853,7 +713,7 @@ pub async fn complete_passkey_setup(
let user = sqlx::query!(
r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
FROM users WHERE did = $1"#,
input.did
input.did.as_str()
)
.fetch_optional(&state.db)
.await;
@@ -861,57 +721,33 @@ pub async fn complete_passkey_setup(
let user = match user {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.password_required {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})),
)
.into_response();
return ApiError::InvalidAccount.into_response();
}
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})),
)
.into_response();
return ApiError::SetupExpired.into_response();
}
};
if let Some(expires_at) = user.recovery_token_expires_at
&& expires_at < Utc::now()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})),
)
.into_response();
return ApiError::SetupExpired.into_response();
}
if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -919,11 +755,7 @@ pub async fn complete_passkey_setup(
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -932,19 +764,11 @@ pub async fn complete_passkey_setup(
{
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})),
)
.into_response();
return ApiError::NoChallengeInProgress.into_response();
}
Err(e) => {
error!("Error loading registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -953,13 +777,7 @@ pub async fn complete_passkey_setup(
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
),
)
.into_response();
return ApiError::InvalidCredential.into_response();
}
};
@@ -967,11 +785,7 @@ pub async fn complete_passkey_setup(
Ok(sk) => sk,
Err(e) => {
warn!("Passkey registration failed: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})),
)
.into_response();
return ApiError::RegistrationFailed.into_response();
}
};
@@ -984,11 +798,7 @@ pub async fn complete_passkey_setup(
.await
{
error!("Error saving passkey: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
@@ -999,11 +809,7 @@ pub async fn complete_passkey_setup(
Ok(h) => h,
Err(e) => {
error!("Error hashing app password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -1017,16 +823,12 @@ pub async fn complete_passkey_setup(
.await
{
error!("Error creating app password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query!(
"UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
input.did
input.did.as_str()
)
.execute(&state.db)
.await
@@ -1037,8 +839,8 @@ pub async fn complete_passkey_setup(
info!(did = %input.did, "Passkey-only account setup completed");
Json(CompletePasskeySetupResponse {
did: input.did,
handle: user.handle,
did: input.did.clone(),
handle: user.handle.into(),
app_password,
app_password_name,
})
@@ -1052,7 +854,7 @@ pub async fn start_passkey_registration_for_setup(
let user = sqlx::query!(
r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
FROM users WHERE did = $1"#,
input.did
input.did.as_str()
)
.fetch_optional(&state.db)
.await;
@@ -1060,57 +862,33 @@ pub async fn start_passkey_registration_for_setup(
let user = match user {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.password_required {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidAccount"})),
)
.into_response();
return ApiError::InvalidAccount.into_response();
}
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "SetupExpired"})),
)
.into_response();
return ApiError::SetupExpired.into_response();
}
};
if let Some(expires_at) = user.recovery_token_expires_at
&& expires_at < Utc::now()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "SetupExpired"})),
)
.into_response();
return ApiError::SetupExpired.into_response();
}
if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "InvalidToken"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -1118,11 +896,7 @@ pub async fn start_passkey_registration_for_setup(
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -1146,11 +920,7 @@ pub async fn start_passkey_registration_for_setup(
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey registration: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -1158,11 +928,7 @@ pub async fn start_passkey_registration_for_setup(
crate::auth::webauthn::save_registration_state(&state.db, &input.did, &reg_state).await
{
error!("Failed to save registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
@@ -1172,7 +938,7 @@ pub async fn start_passkey_registration_for_setup(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartPasskeyRegistrationInput {
pub did: String,
pub did: Did,
pub setup_token: String,
pub friendly_name: Option<String>,
}
@@ -1194,11 +960,7 @@ pub async fn request_passkey_recovery(
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
.await
{
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({"error": "RateLimitExceeded"})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -1221,7 +983,7 @@ pub async fn request_passkey_recovery(
let user = match user {
Ok(Some(u)) if !u.password_required => u,
_ => {
return Json(json!({"success": true})).into_response();
return SuccessResponse::ok().into_response();
}
};
@@ -1229,11 +991,7 @@ pub async fn request_passkey_recovery(
let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
Ok(h) => h,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let expires_at = Utc::now() + Duration::hours(1);
@@ -1242,17 +1000,13 @@ pub async fn request_passkey_recovery(
"UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
recovery_token_hash,
expires_at,
user.did
&user.did
)
.execute(&state.db)
.await
{
error!("Error updating recovery token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
@@ -1267,15 +1021,15 @@ pub async fn request_passkey_recovery(
crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
info!(did = %user.did, "Passkey recovery requested");
Json(json!({"success": true})).into_response()
SuccessResponse::ok().into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecoverPasskeyAccountInput {
pub did: String,
pub did: Did,
pub recovery_token: String,
pub new_password: String,
pub new_password: PlainPassword,
}
pub async fn recover_passkey_account(
@@ -1283,19 +1037,12 @@ pub async fn recover_passkey_account(
Json(input): Json<RecoverPasskeyAccountInput>,
) -> Response {
if let Err(e) = validate_password(&input.new_password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let user = sqlx::query!(
"SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
input.did
input.did.as_str()
)
.fetch_optional(&state.db)
.await;
@@ -1303,71 +1050,47 @@ pub async fn recover_passkey_account(
let user = match user {
Ok(Some(u)) => u,
_ => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "InvalidRecoveryLink"})),
)
.into_response();
return ApiError::InvalidRecoveryLink.into_response();
}
};
let token_hash = match &user.recovery_token {
Some(h) => h,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecoveryLink"})),
)
.into_response();
return ApiError::InvalidRecoveryLink.into_response();
}
};
if let Some(expires_at) = user.recovery_token_expires_at
&& expires_at < Utc::now()
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "RecoveryLinkExpired"})),
)
.into_response();
return ApiError::RecoveryLinkExpired.into_response();
}
if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "InvalidRecoveryLink"})),
)
.into_response();
return ApiError::InvalidRecoveryLink.into_response();
}
let password_hash = match hash(&input.new_password, DEFAULT_COST) {
Ok(h) => h,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
password_hash,
input.did
input.did.as_str()
)
.execute(&state.db)
.await
{
error!("Error updating password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did)
let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str())
.execute(&state.db)
.await;
match deleted {
@@ -1382,5 +1105,5 @@ pub async fn recover_passkey_account(
}
info!(did = %input.did, "Passkey-only account recovered with temporary password");
Json(json!({"success": true})).into_response()
SuccessResponse::ok().into_response()
}
+25 -104
View File
@@ -1,3 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuth;
use crate::auth::webauthn::{
self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state,
@@ -8,22 +10,17 @@ 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, info, warn};
use webauthn_rs::prelude::*;
fn get_webauthn() -> Result<WebAuthnConfig, (StatusCode, Json<serde_json::Value>)> {
fn get_webauthn() -> Result<WebAuthnConfig, ApiError> {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
WebAuthnConfig::new(&hostname).map_err(|e| {
error!("Failed to create WebAuthn config: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})),
)
ApiError::InternalError(Some("WebAuthn configuration failed".into()))
})
}
@@ -49,26 +46,18 @@ pub async fn start_passkey_registration(
Err(e) => return e.into_response(),
};
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did)
let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", &*auth.0.did)
.fetch_optional(&state.db)
.await;
let handle = match user {
Ok(Some(row)) => row.handle,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -76,11 +65,7 @@ pub async fn start_passkey_registration(
Ok(passkeys) => passkeys,
Err(e) => {
error!("DB error fetching existing passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -100,24 +85,17 @@ pub async fn start_passkey_registration(
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey registration: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to start registration"})),
)
return ApiError::InternalError(Some("Failed to start registration".into()))
.into_response();
}
};
if let Err(e) = save_registration_state(&state.db, &auth.0.did, &reg_state).await {
error!("Failed to save registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({}));
info!(did = %auth.0.did, "Passkey registration started");
@@ -151,22 +129,11 @@ pub async fn finish_passkey_registration(
let reg_state = match load_registration_state(&state.db, &auth.0.did).await {
Ok(Some(state)) => state,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoRegistrationInProgress",
"message": "No registration in progress. Call startPasskeyRegistration first."
})),
)
.into_response();
return ApiError::NoRegistrationInProgress.into_response();
}
Err(e) => {
error!("DB error loading registration state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -174,14 +141,7 @@ pub async fn finish_passkey_registration(
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidCredential",
"message": "Failed to parse credential response"
})),
)
.into_response();
return ApiError::InvalidCredential.into_response();
}
};
@@ -189,14 +149,7 @@ pub async fn finish_passkey_registration(
Ok(pk) => pk,
Err(e) => {
warn!("Failed to finish passkey registration: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "RegistrationFailed",
"message": "Failed to verify passkey registration"
})),
)
.into_response();
return ApiError::RegistrationFailed.into_response();
}
};
@@ -211,11 +164,7 @@ pub async fn finish_passkey_registration(
Ok(id) => id,
Err(e) => {
error!("Failed to save passkey: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -258,11 +207,7 @@ pub async fn list_passkeys(State(state): State<AppState>, auth: BearerAuth) -> R
Ok(pks) => pks,
Err(e) => {
error!("DB error fetching passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -306,31 +251,19 @@ pub async fn delete_passkey(
let id: uuid::Uuid = match input.id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
)
.into_response();
return ApiError::InvalidId.into_response();
}
};
match db_delete_passkey(&state.db, id, &auth.0.did).await {
Ok(true) => {
info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted");
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
)
.into_response(),
Ok(false) => ApiError::PasskeyNotFound.into_response(),
Err(e) => {
error!("DB error deleting passkey: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -350,31 +283,19 @@ pub async fn update_passkey(
let id: uuid::Uuid = match input.id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})),
)
.into_response();
return ApiError::InvalidId.into_response();
}
};
match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await {
Ok(true) => {
info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed");
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})),
)
.into_response(),
Ok(false) => ApiError::PasskeyNotFound.into_response(),
Err(e) => {
error!("DB error updating passkey: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+55 -214
View File
@@ -1,16 +1,18 @@
use crate::api::error::ApiError;
use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
use crate::auth::BearerAuth;
use crate::state::{AppState, RateLimitKind};
use crate::types::PlainPassword;
use crate::validation::validate_password;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash, verify};
use chrono::{Duration, Utc};
use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
use uuid::Uuid;
@@ -49,22 +51,11 @@ pub async fn request_password_reset(
.await
{
warn!(ip = %client_ip, "Password reset rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many password reset requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let identifier = input.email.trim();
if identifier.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})),
)
.into_response();
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized = identifier.to_lowercase();
@@ -85,15 +76,11 @@ pub async fn request_password_reset(
Ok(Some(row)) => row.id,
Ok(None) => {
info!("Password reset requested for unknown identifier");
return (StatusCode::OK, Json(json!({}))).into_response();
return EmptyResponse::ok().into_response();
}
Err(e) => {
error!("DB error in request_password_reset: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let code = generate_reset_code();
@@ -108,11 +95,7 @@ pub async fn request_password_reset(
.await;
if let Err(e) = update {
error!("DB error setting reset code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
@@ -120,13 +103,13 @@ pub async fn request_password_reset(
warn!("Failed to enqueue password reset notification: {:?}", e);
}
info!("Password reset requested for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
pub struct ResetPasswordInput {
pub token: String,
pub password: String,
pub password: PlainPassword,
}
pub async fn reset_password(
@@ -140,40 +123,18 @@ pub async fn reset_password(
.await
{
warn!(ip = %client_ip, "Reset password rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let token = input.token.trim();
let password = &input.password;
if token.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "token is required"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
if password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "password is required"})),
)
.into_response();
return ApiError::InvalidRequest("password is required".into()).into_response();
}
if let Err(e) = validate_password(password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let user = sqlx::query!(
"SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
@@ -187,19 +148,11 @@ pub async fn reset_password(
(row.id, expires)
}
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
Err(e) => {
error!("DB error in reset_password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Some(exp) = expires_at {
@@ -213,18 +166,10 @@ pub async fn reset_password(
{
error!("Failed to clear expired reset code: {:?}", e);
}
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
return ApiError::ExpiredToken(None).into_response();
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})),
)
.into_response();
return ApiError::InvalidToken(None).into_response();
}
let password_clone = password.to_string();
let password_hash =
@@ -232,30 +177,18 @@ pub async fn reset_password(
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
@@ -267,11 +200,7 @@ pub async fn reset_password(
.await
{
error!("DB error updating password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
.fetch_one(&mut *tx)
@@ -280,11 +209,7 @@ pub async fn reset_password(
Ok(did) => did,
Err(e) => {
error!("Failed to get DID for user {}: {:?}", user_id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let session_jtis: Vec<String> = match sqlx::query_scalar!(
@@ -308,19 +233,11 @@ pub async fn reset_password(
"Failed to invalidate sessions after password reset: {:?}",
e
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
for jti in session_jtis {
let cache_key = format!("auth:session:{}:{}", user_did, jti);
@@ -332,14 +249,14 @@ pub async fn reset_password(
}
}
info!("Password reset completed for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangePasswordInput {
pub current_password: String,
pub new_password: String,
pub current_password: PlainPassword,
pub new_password: PlainPassword,
}
pub async fn change_password(
@@ -355,28 +272,13 @@ pub async fn change_password(
let current_password = &input.current_password;
let new_password = &input.new_password;
if current_password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})),
)
.into_response();
return ApiError::InvalidRequest("currentPassword is required".into()).into_response();
}
if new_password.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})),
)
.into_response();
return ApiError::InvalidRequest("newPassword is required".into()).into_response();
}
if let Err(e) = validate_password(new_password) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidPassword",
"message": e.to_string()
})),
)
.into_response();
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let user =
sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1")
@@ -386,38 +288,22 @@ pub async fn change_password(
let (user_id, password_hash) = match user {
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error in change_password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let valid = match verify(current_password, &password_hash) {
Ok(v) => v,
Err(e) => {
error!("Password verification error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if !valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})),
)
.into_response();
return ApiError::InvalidPassword("Current password is incorrect".into()).into_response();
}
let new_password_clone = new_password.to_string();
let new_hash =
@@ -425,19 +311,11 @@ pub async fn change_password(
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Failed to hash password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
@@ -447,40 +325,26 @@ pub async fn change_password(
.await
{
error!("DB error updating password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
info!(did = %auth.0.did, "Password changed successfully");
(StatusCode::OK, Json(json!({}))).into_response()
info!(did = %&auth.0.did, "Password changed successfully");
EmptyResponse::ok().into_response()
}
pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let user = sqlx::query!(
"SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
match user {
Ok(Some(row)) => {
Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Ok(Some(row)) => HasPasswordResponse::new(row.has_password.unwrap_or(false)).into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -504,19 +368,15 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
let has_passkeys =
crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await;
if !has_passkeys {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPasskeys",
"message": "You must have at least one passkey registered before removing your password"
})),
return ApiError::InvalidRequest(
"You must have at least one passkey registered before removing your password".into(),
)
.into_response();
.into_response();
}
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE did = $1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
@@ -524,31 +384,16 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
let user = match user {
Ok(Some(u)) => u,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if user.password_hash.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPassword",
"message": "Account already has no password"
})),
)
.into_response();
return ApiError::InvalidRequest("Account already has no password".into()).into_response();
}
if let Err(e) = sqlx::query!(
@@ -559,13 +404,9 @@ pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) ->
.await
{
error!("DB error removing password: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
info!(did = %auth.0.did, "Password removed - account is now passkey-only");
(StatusCode::OK, Json(json!({"success": true}))).into_response()
info!(did = %&auth.0.did, "Password removed - account is now passkey-only");
SuccessResponse::ok().into_response()
}
+50 -147
View File
@@ -1,3 +1,4 @@
use crate::api::error::ApiError;
use axum::{
Json,
extract::State,
@@ -6,12 +7,12 @@ use axum::{
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing::{error, info, warn};
use crate::auth::BearerAuth;
use crate::state::{AppState, RateLimitKind};
use crate::types::PlainPassword;
const REAUTH_WINDOW_SECONDS: i64 = 300;
@@ -26,7 +27,7 @@ pub struct ReauthStatusResponse {
pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let session = sqlx::query!(
"SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
@@ -36,11 +37,7 @@ pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth)
Ok(None) => None,
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -58,7 +55,7 @@ pub async fn get_reauth_status(State(state): State<AppState>, auth: BearerAuth)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasswordReauthInput {
pub password: String,
pub password: PlainPassword,
}
#[derive(Serialize)]
@@ -72,26 +69,18 @@ pub async fn reauth_password(
auth: BearerAuth,
Json(input): Json<PasswordReauthInput>,
) -> Response {
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response();
return ApiError::AccountNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -105,7 +94,7 @@ pub async fn reauth_password(
"SELECT ap.password_hash FROM app_passwords ap
JOIN users u ON ap.user_id = u.id
WHERE u.did = $1",
auth.0.did
&auth.0.did
)
.fetch_all(&state.db)
.await
@@ -116,30 +105,19 @@ pub async fn reauth_password(
.any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false));
if !app_password_valid {
warn!(did = %auth.0.did, "Re-auth failed: invalid password");
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
warn!(did = %&auth.0.did, "Re-auth failed: invalid password");
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
}
}
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via password");
info!(did = %&auth.0.did, "Re-auth successful via password");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -159,15 +137,9 @@ pub async fn reauth_totp(
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
return ApiError::RateLimitExceeded(Some("Too many verification attempts. Please try again in a few minutes.".into(),))
.into_response();
}
let valid =
@@ -175,29 +147,18 @@ pub async fn reauth_totp(
.await;
if !valid {
warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code");
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid TOTP or backup code"
})),
)
.into_response();
warn!(did = %&auth.0.did, "Re-auth failed: invalid TOTP code");
return ApiError::InvalidCode(Some("Invalid TOTP or backup code".into())).into_response();
}
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via TOTP");
info!(did = %&auth.0.did, "Re-auth successful via TOTP");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -216,23 +177,12 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
Ok(pks) => pks,
Err(e) => {
error!("Failed to get passkeys: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if stored_passkeys.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoPasskeys",
"message": "No passkeys registered for this account"
})),
)
.into_response();
return ApiError::NoPasskeys.into_response();
}
let passkeys: Vec<webauthn_rs::prelude::SecurityKey> = stored_passkeys
@@ -241,22 +191,14 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
.collect();
if passkeys.is_empty() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to load passkeys".into())).into_response();
}
let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -264,11 +206,7 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
Ok(result) => result,
Err(e) => {
error!("Failed to start passkey authentication: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -276,14 +214,10 @@ pub async fn reauth_passkey_start(State(state): State<AppState>, auth: BearerAut
crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await
{
error!("Failed to save authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let options = serde_json::to_value(&rcr).unwrap_or(json!({}));
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
Json(PasskeyReauthStartResponse { options }).into_response()
}
@@ -304,22 +238,11 @@ pub async fn reauth_passkey_finish(
match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await {
Ok(Some(s)) => s,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "NoChallengeInProgress",
"message": "No passkey authentication in progress or challenge expired"
})),
)
.into_response();
return ApiError::NoChallengeInProgress.into_response();
}
Err(e) => {
error!("Failed to load authentication state: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -328,14 +251,7 @@ pub async fn reauth_passkey_finish(
Ok(c) => c,
Err(e) => {
warn!("Failed to parse credential: {:?}", e);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidCredential",
"message": "Failed to parse credential response"
})),
)
.into_response();
return ApiError::InvalidCredential.into_response();
}
};
@@ -343,25 +259,15 @@ pub async fn reauth_passkey_finish(
Ok(w) => w,
Err(e) => {
error!("Failed to create WebAuthn config: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let auth_result = match webauthn.finish_authentication(&credential, &auth_state) {
Ok(r) => r,
Err(e) => {
warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": "Passkey authentication failed"
})),
)
warn!(did = %&auth.0.did, "Passkey re-auth failed: {:?}", e);
return ApiError::AuthenticationFailed(Some("Passkey authentication failed".into()))
.into_response();
}
};
@@ -375,17 +281,10 @@ pub async fn reauth_passkey_finish(
.await
{
Ok(false) => {
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
warn!(did = %&auth.0.did, "Passkey counter anomaly detected - possible cloned key");
let _ =
crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "PasskeyCounterAnomaly",
"message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
})),
)
.into_response();
return ApiError::PasskeyCounterAnomaly.into_response();
}
Err(e) => {
error!("Failed to update passkey counter: {:?}", e);
@@ -397,16 +296,12 @@ pub async fn reauth_passkey_finish(
match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await {
Ok(reauthed_at) => {
info!(did = %auth.0.did, "Re-auth successful via passkey");
info!(did = %&auth.0.did, "Re-auth successful via passkey");
Json(ReauthResponse { reauthed_at }).into_response()
}
Err(e) => {
error!("DB error updating reauth: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -582,11 +477,19 @@ pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
let methods = get_available_reauth_methods(db, did).await;
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "MfaVerificationRequired",
"message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
"reauthMethods": methods
})),
Json(MfaVerificationRequiredError {
error: "MfaVerificationRequired".to_string(),
message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(),
reauth_methods: methods,
}),
)
.into_response()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MfaVerificationRequiredError {
pub error: String,
pub message: String,
pub reauth_methods: Vec<String>,
}
+30 -68
View File
@@ -1,4 +1,6 @@
use crate::api::ApiError;
use crate::types::Did;
use crate::AccountStatus;
use crate::api::error::ApiError;
use crate::state::AppState;
use axum::{
Json,
@@ -92,10 +94,10 @@ pub async fn get_service_auth(
.await
{
Ok(result) => crate::auth::AuthenticatedUser {
did: result.did,
did: Did::new_unchecked(result.did),
is_oauth: true,
is_admin: false,
is_takendown: false,
status: AccountStatus::Active,
scope: result.scope,
key_bytes: None,
controller_did: None,
@@ -113,14 +115,7 @@ pub async fn get_service_auth(
}
Err(e) => {
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AuthenticationFailed",
"message": format!("{:?}", e)
})),
)
.into_response();
return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();
}
}
} else {
@@ -133,7 +128,7 @@ pub async fn get_service_auth(
}
};
info!(
did = %auth_user.did,
did = %&auth_user.did,
is_oauth = auth_user.is_oauth,
has_key = auth_user.key_bytes.is_some(),
"getServiceAuth auth validated"
@@ -141,7 +136,7 @@ pub async fn get_service_auth(
let key_bytes = match &auth_user.key_bytes {
Some(kb) => kb.clone(),
None => {
warn!(did = %auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
warn!(did = %&auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB");
match sqlx::query_as::<_, (Vec<u8>, Option<i32>)>(
"SELECT k.key_bytes, k.encryption_version
FROM users u
@@ -157,20 +152,20 @@ pub async fn get_service_auth(
Ok(key) => key,
Err(e) => {
error!(error = ?e, "Failed to decrypt user key for service auth");
return ApiError::AuthenticationFailedMsg(
return ApiError::AuthenticationFailed(Some(
"Failed to get signing key".into(),
)
))
.into_response();
}
}
}
Ok(None) => {
return ApiError::AuthenticationFailedMsg("User has no signing key".into())
return ApiError::AuthenticationFailed(Some("User has no signing key".into()))
.into_response();
}
Err(e) => {
error!(error = ?e, "DB error fetching user key");
return ApiError::AuthenticationFailedMsg("Failed to get signing key".into())
return ApiError::AuthenticationFailed(Some("Failed to get signing key".into()))
.into_response();
}
}
@@ -192,20 +187,16 @@ pub async fn get_service_auth(
} else if auth_user.is_oauth {
let permissions = auth_user.permissions();
if !permissions.has_full_access() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": "OAuth tokens with granular scopes must specify an lxm parameter"
})),
return ApiError::InvalidRequest(
"OAuth tokens with granular scopes must specify an lxm parameter".into(),
)
.into_response();
.into_response();
}
}
let user_status = sqlx::query!(
"SELECT takedown_ref FROM users WHERE did = $1",
auth_user.did
&auth_user.did
)
.fetch_optional(&state.db)
.await;
@@ -216,27 +207,17 @@ pub async fn get_service_auth(
};
if is_takendown && lxm != Some("com.atproto.server.createAccount") {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidToken",
"message": "Bad token scope"
})),
)
.into_response();
return ApiError::InvalidToken(Some("Bad token scope".into())).into_response();
}
if let Some(method) = lxm
&& PROTECTED_METHODS.contains(&method)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("cannot request a service auth token for the following protected method: {}", method)
})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"cannot request a service auth token for the following protected method: {}",
method
))
.into_response();
}
if let Some(exp) = params.exp {
@@ -244,36 +225,21 @@ pub async fn get_service_auth(
let diff = exp - now;
if diff < 0 {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "BadExpiration",
"message": "expiration is in past"
})),
)
.into_response();
return ApiError::InvalidRequest("expiration is in past".into()).into_response();
}
if diff > HOUR_SECS {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "BadExpiration",
"message": "cannot request a token with an expiration more than an hour in the future"
})),
return ApiError::InvalidRequest(
"cannot request a token with an expiration more than an hour in the future".into(),
)
.into_response();
.into_response();
}
if lxm.is_none() && diff > MINUTE_SECS {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "BadExpiration",
"message": "cannot request a method-less token with an expiration more than a minute in the future"
})),
return ApiError::InvalidRequest(
"cannot request a method-less token with an expiration more than a minute in the future".into(),
)
.into_response();
.into_response();
}
}
@@ -286,11 +252,7 @@ pub async fn get_service_auth(
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
(
+162 -275
View File
@@ -1,6 +1,8 @@
use crate::api::ApiError;
use crate::api::error::ApiError;
use crate::api::{EmptyResponse, SuccessResponse};
use crate::auth::{BearerAuth, BearerAuthAllowDeactivated};
use crate::state::{AppState, RateLimitKind};
use crate::types::{AccountState, Did, Handle, PlainPassword};
use axum::{
Json,
extract::State,
@@ -46,7 +48,7 @@ fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String {
#[serde(rename_all = "camelCase")]
pub struct CreateSessionInput {
pub identifier: String,
pub password: String,
pub password: PlainPassword,
#[serde(default)]
pub allow_takendown: bool,
}
@@ -56,8 +58,8 @@ pub struct CreateSessionInput {
pub struct CreateSessionOutput {
pub access_jwt: String,
pub refresh_jwt: String,
pub handle: String,
pub did: String,
pub handle: Handle,
pub did: Did,
#[serde(skip_serializing_if = "Option::is_none")]
pub did_doc: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -85,14 +87,7 @@ pub async fn create_session(
.await
{
warn!(ip = %client_ip, "Login rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many login attempts. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
@@ -123,19 +118,19 @@ pub async fn create_session(
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
warn!("User not found for login attempt");
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into()))
.into_response();
}
Err(e) => {
error!("Database error fetching user: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row
@@ -168,20 +163,18 @@ pub async fn create_session(
};
if !password_valid {
warn!("Password verification failed for login attempt");
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into()))
.into_response();
}
let is_takendown = row.takedown_ref.is_some();
if is_takendown && !input.allow_takendown {
let account_state = AccountState::from_db_fields(
row.deactivated_at,
row.takedown_ref.clone(),
row.migrated_to_pds.clone(),
None,
);
if account_state.is_takendown() && !input.allow_takendown {
warn!("Login attempt for takendown account: {}", row.did);
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "AccountTakedown",
"message": "Account has been taken down"
})),
)
.into_response();
return ApiError::AccountTakedown.into_response();
}
let is_verified =
row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified;
@@ -223,14 +216,14 @@ pub async fn create_session(
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let did_for_doc = row.did.clone();
@@ -254,7 +247,7 @@ pub async fn create_session(
);
if let Err(e) = insert_result {
error!("Failed to insert session: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
if is_legacy_login {
warn!(
@@ -276,22 +269,13 @@ pub async fn create_session(
}
}
let handle = full_handle(&row.handle, &pds_hostname);
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
let is_active = row.deactivated_at.is_none() && !is_takendown;
let status = if is_takendown {
Some("takendown".to_string())
} else if is_migrated {
Some("migrated".to_string())
} else if row.deactivated_at.is_some() {
Some("deactivated".to_string())
} else {
None
};
let is_active = account_state.is_active();
let status = account_state.status_for_session().map(String::from);
Json(CreateSessionOutput {
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
handle,
did: row.did,
handle: handle.into(),
did: row.did.into(),
did_doc,
email: row.email,
email_confirmed: Some(row.email_verified),
@@ -317,7 +301,7 @@ pub async fn get_session(
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
FROM users WHERE did = $1"#,
auth_user.did
&auth_user.did
)
.fetch_optional(&state.db),
did_resolver.resolve_did_document(&did_for_doc)
@@ -333,9 +317,12 @@ pub async fn get_session(
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&row.handle, &pds_hostname);
let is_takendown = row.takedown_ref.is_some();
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
let is_active = row.deactivated_at.is_none() && !is_takendown;
let account_state = AccountState::from_db_fields(
row.deactivated_at,
row.takedown_ref.clone(),
row.migrated_to_pds.clone(),
row.migrated_at,
);
let email_value = if can_read_email {
row.email.clone()
} else {
@@ -344,8 +331,8 @@ pub async fn get_session(
let email_confirmed_value = can_read_email && row.email_verified;
let mut response = json!({
"handle": handle,
"did": auth_user.did,
"active": is_active,
"did": &auth_user.did,
"active": account_state.is_active(),
"preferredChannel": preferred_channel,
"preferredChannelVerified": preferred_channel_verified,
"preferredLocale": row.preferred_locale,
@@ -355,24 +342,22 @@ pub async fn get_session(
response["email"] = json!(email_value);
response["emailConfirmed"] = json!(email_confirmed_value);
}
if is_takendown {
response["status"] = json!("takendown");
} else if is_migrated {
response["status"] = json!("migrated");
response["migratedToPds"] = json!(row.migrated_to_pds);
response["migratedAt"] = json!(row.migrated_at);
} else if row.deactivated_at.is_some() {
response["status"] = json!("deactivated");
if let Some(status) = account_state.status_for_session() {
response["status"] = json!(status);
}
if let AccountState::Migrated { to_pds, at } = &account_state {
response["migratedToPds"] = json!(to_pds);
response["migratedAt"] = json!(at);
}
if let Some(doc) = did_doc {
response["didDoc"] = doc;
}
Json(response).into_response()
}
Ok(None) => ApiError::AuthenticationFailed.into_response(),
Ok(None) => ApiError::AuthenticationFailed(None).into_response(),
Err(e) => {
error!("Database error in get_session: {:?}", e);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -389,7 +374,7 @@ pub async fn delete_session(
};
let jti = match crate::auth::get_jti_from_token(&token) {
Ok(jti) => jti,
Err(_) => return ApiError::AuthenticationFailed.into_response(),
Err(_) => return ApiError::AuthenticationFailed(None).into_response(),
};
let did = crate::auth::get_did_from_token(&token).ok();
match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti)
@@ -401,12 +386,12 @@ pub async fn delete_session(
let session_cache_key = format!("auth:session:{}:{}", did, jti);
let _ = state.cache.delete(&session_cache_key).await;
}
Json(json!({})).into_response()
EmptyResponse::ok().into_response()
}
Ok(_) => ApiError::AuthenticationFailed.into_response(),
Ok(_) => ApiError::AuthenticationFailed(None).into_response(),
Err(e) => {
error!("Database error in delete_session: {:?}", e);
ApiError::AuthenticationFailed.into_response()
ApiError::AuthenticationFailed(None).into_response()
}
}
}
@@ -421,14 +406,7 @@ pub async fn refresh_session(
.await
{
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
axum::Json(serde_json::json!({
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
)
.into_response();
return ApiError::RateLimitExceeded(None).into_response();
}
let refresh_token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
@@ -439,7 +417,7 @@ pub async fn refresh_session(
let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) {
Ok(jti) => jti,
Err(_) => {
return ApiError::AuthenticationFailedMsg("Invalid token format".into())
return ApiError::AuthenticationFailed(Some("Invalid token format".into()))
.into_response();
}
};
@@ -447,7 +425,7 @@ pub async fn refresh_session(
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Ok(Some(session_id)) = sqlx::query_scalar!(
@@ -465,9 +443,9 @@ pub async fn refresh_session(
.execute(&mut *tx)
.await;
let _ = tx.commit().await;
return ApiError::ExpiredTokenMsg(
return ApiError::AuthenticationFailed(Some(
"Refresh token has been revoked due to suspected compromise".into(),
)
))
.into_response();
}
let session_row = match sqlx::query!(
@@ -484,12 +462,12 @@ pub async fn refresh_session(
{
Ok(Some(row)) => row,
Ok(None) => {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into())
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()))
.into_response();
}
Err(e) => {
error!("Database error fetching session: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let key_bytes =
@@ -497,11 +475,11 @@ pub async fn refresh_session(
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())).into_response();
}
let new_access_meta = match crate::auth::create_access_token_with_delegation(
&session_row.did,
@@ -512,7 +490,7 @@ pub async fn refresh_session(
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let new_refresh_meta =
@@ -520,7 +498,7 @@ pub async fn refresh_session(
Ok(m) => m,
Err(e) => {
error!("Failed to create refresh token: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
match sqlx::query!(
@@ -537,11 +515,11 @@ pub async fn refresh_session(
.execute(&mut *tx)
.await;
let _ = tx.commit().await;
return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response();
return ApiError::AuthenticationFailed(Some("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();
return ApiError::InternalError(None).into_response();
}
Ok(_) => {}
}
@@ -557,11 +535,11 @@ pub async fn refresh_session(
.await
{
error!("Database error updating session: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = tx.commit().await {
error!("Failed to commit transaction: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
let did_for_doc = session_row.did.clone();
let did_resolver = state.did_resolver.clone();
@@ -588,8 +566,12 @@ pub async fn refresh_session(
let pds_hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&u.handle, &pds_hostname);
let is_takendown = u.takedown_ref.is_some();
let is_active = u.deactivated_at.is_none() && !is_takendown;
let account_state = AccountState::from_db_fields(
u.deactivated_at,
u.takedown_ref.clone(),
None,
None,
);
let mut response = json!({
"accessJwt": new_access_meta.token,
"refreshJwt": new_refresh_meta.token,
@@ -601,25 +583,23 @@ pub async fn refresh_session(
"preferredChannelVerified": preferred_channel_verified,
"preferredLocale": u.preferred_locale,
"isAdmin": u.is_admin,
"active": is_active
"active": account_state.is_active()
});
if let Some(doc) = did_doc {
response["didDoc"] = doc;
}
if is_takendown {
response["status"] = json!("takendown");
} else if u.deactivated_at.is_some() {
response["status"] = json!("deactivated");
if let Some(status) = account_state.status_for_session() {
response["status"] = json!(status);
}
Json(response).into_response()
}
Ok(None) => {
error!("User not found for existing session: {}", session_row.did);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
Err(e) => {
error!("Database error fetching user: {:?}", e);
ApiError::InternalError.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -627,7 +607,7 @@ pub async fn refresh_session(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmSignupInput {
pub did: String,
pub did: Did,
pub verification_code: String,
}
@@ -636,8 +616,8 @@ pub struct ConfirmSignupInput {
pub struct ConfirmSignupOutput {
pub access_jwt: String,
pub refresh_jwt: String,
pub handle: String,
pub did: String,
pub handle: Handle,
pub did: Did,
pub email: Option<String>,
pub email_verified: bool,
pub preferred_channel: String,
@@ -658,7 +638,7 @@ pub async fn confirm_signup(
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1"#,
input.did
input.did.as_str()
)
.fetch_optional(&state.db)
.await
@@ -671,7 +651,7 @@ pub async fn confirm_signup(
}
Err(e) => {
error!("Database error in confirm_signup: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -697,7 +677,7 @@ pub async fn confirm_signup(
&identifier,
) {
Ok(token_data) => {
if token_data.did != input.did {
if token_data.did != input.did.as_str() {
warn!(
"Token DID mismatch for confirm_signup: expected {}, got {}",
input.did, token_data.did
@@ -708,7 +688,7 @@ pub async fn confirm_signup(
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
warn!("Verification code expired for user: {}", input.did);
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
return ApiError::ExpiredToken(Some("Verification code has expired".into()))
.into_response();
}
Err(e) => {
@@ -721,7 +701,7 @@ pub async fn confirm_signup(
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let verified_column = match row.channel {
@@ -732,26 +712,26 @@ pub async fn confirm_signup(
};
let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column);
if let Err(e) = sqlx::query(&update_query)
.bind(&input.did)
.bind(input.did.as_str())
.execute(&state.db)
.await
{
error!("Failed to update verification status: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
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();
return ApiError::InternalError(None).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();
return ApiError::InternalError(None).into_response();
}
};
let no_scope: Option<String> = None;
@@ -770,7 +750,7 @@ pub async fn confirm_signup(
.await
{
error!("Failed to insert session: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await {
@@ -786,8 +766,8 @@ pub async fn confirm_signup(
Json(ConfirmSignupOutput {
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
handle: row.handle,
did: row.did,
handle: row.handle.into(),
did: row.did.into(),
email: row.email,
email_verified,
preferred_channel: preferred_channel.to_string(),
@@ -799,7 +779,7 @@ pub async fn confirm_signup(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResendVerificationInput {
pub did: String,
pub did: Did,
}
pub async fn resend_verification(
@@ -815,7 +795,7 @@ pub async fn resend_verification(
email_verified, discord_verified, telegram_verified, signal_verified
FROM users
WHERE did = $1"#,
input.did
input.did.as_str()
)
.fetch_optional(&state.db)
.await
@@ -826,7 +806,7 @@ pub async fn resend_verification(
}
Err(e) => {
error!("Database error in resend_verification: {:?}", e);
return ApiError::InternalError.into_response();
return ApiError::InternalError(None).into_response();
}
};
let is_verified =
@@ -866,7 +846,7 @@ pub async fn resend_verification(
{
warn!("Failed to enqueue verification notification: {:?}", e);
}
Json(json!({"success": true})).into_response()
SuccessResponse::ok().into_response()
}
#[derive(Serialize)]
@@ -934,11 +914,7 @@ pub async fn list_sessions(
}
Err(e) => {
error!("DB error fetching JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
@@ -980,11 +956,7 @@ pub async fn list_sessions(
}
Err(e) => {
error!("DB error fetching OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
@@ -1015,15 +987,8 @@ pub async fn revoke_session(
Json(input): Json<RevokeSessionInput>,
) -> Response {
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
let session_id: i32 = match jwt_id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
)
.into_response();
}
let Ok(session_id) = jwt_id.parse::<i32>() else {
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
};
let session = sqlx::query_as::<_, (String,)>(
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
@@ -1035,19 +1000,11 @@ pub async fn revoke_session(
let access_jti = match session {
Ok(Some((jti,))) => jti,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
)
.into_response();
return ApiError::SessionNotFound.into_response();
}
Err(e) => {
error!("DB error in revoke_session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
@@ -1056,27 +1013,16 @@ pub async fn revoke_session(
.await
{
error!("DB error deleting session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
let cache_key = format!("auth:session:{}:{}", &auth.0.did, access_jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache: {:?}", e);
}
info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked");
info!(did = %&auth.0.did, session_id = %session_id, "JWT session revoked");
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
let session_id: i32 = match oauth_id.parse() {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
)
.into_response();
}
let Ok(session_id) = oauth_id.parse::<i32>() else {
return ApiError::InvalidRequest("Invalid session ID".into()).into_response();
};
let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2")
.bind(session_id)
@@ -1085,31 +1031,19 @@ pub async fn revoke_session(
.await;
match result {
Ok(r) if r.rows_affected() == 0 => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
)
.into_response();
return ApiError::SessionNotFound.into_response();
}
Err(e) => {
error!("DB error deleting OAuth session: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
_ => {}
}
info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked");
info!(did = %&auth.0.did, session_id = %session_id, "OAuth session revoked");
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})),
)
.into_response();
return ApiError::InvalidRequest("Invalid session ID format".into()).into_response();
}
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
pub async fn revoke_all_sessions(
@@ -1123,71 +1057,51 @@ pub async fn revoke_all_sessions(
.and_then(|v| v.strip_prefix("Bearer "))
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
if let Some(ref jti) = current_jti {
if auth.0.is_oauth {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
let Some(ref jti) = current_jti else {
return ApiError::InvalidToken(None).into_response();
};
if auth.0.is_oauth {
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
} else {
if let Err(e) =
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
} else {
if let Err(e) =
sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
.bind(&auth.0.did)
.bind(jti)
.execute(&state.db)
.await
{
error!("DB error revoking JWT sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
{
error!("DB error revoking JWT sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
.bind(&auth.0.did)
.execute(&state.db)
.await
{
error!("DB error revoking OAuth sessions: {:?}", e);
return ApiError::InternalError(None).into_response();
}
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})),
)
.into_response();
}
info!(did = %auth.0.did, "All other sessions revoked");
(StatusCode::OK, Json(json!({"success": true}))).into_response()
info!(did = %&auth.0.did, "All other sessions revoked");
SuccessResponse::ok().into_response()
}
#[derive(Serialize)]
@@ -1207,7 +1121,7 @@ pub async fn get_legacy_login_preference(
(EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR
EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!"
FROM users u WHERE u.did = $1"#,
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
@@ -1218,18 +1132,10 @@ pub async fn get_legacy_login_preference(
has_mfa: row.has_mfa,
})
.into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -1257,7 +1163,7 @@ pub async fn update_legacy_login_preference(
let result = sqlx::query!(
"UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
input.allow_legacy_login,
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
@@ -1265,7 +1171,7 @@ pub async fn update_legacy_login_preference(
match result {
Ok(Some(_)) => {
info!(
did = %auth.0.did,
did = %&auth.0.did,
allow_legacy_login = input.allow_legacy_login,
"Legacy login preference updated"
);
@@ -1274,18 +1180,10 @@ pub async fn update_legacy_login_preference(
}))
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -1304,20 +1202,17 @@ pub async fn update_locale(
Json(input): Json<UpdateLocaleInput>,
) -> Response {
if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "InvalidRequest",
"message": format!("Invalid locale. Valid options: {}", VALID_LOCALES.join(", "))
})),
)
.into_response();
return ApiError::InvalidRequest(format!(
"Invalid locale. Valid options: {}",
VALID_LOCALES.join(", ")
))
.into_response();
}
let result = sqlx::query!(
"UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did",
input.preferred_locale,
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
@@ -1325,7 +1220,7 @@ pub async fn update_locale(
match result {
Ok(Some(_)) => {
info!(
did = %auth.0.did,
did = %&auth.0.did,
locale = %input.preferred_locale,
"User locale preference updated"
);
@@ -1334,18 +1229,10 @@ pub async fn update_locale(
}))
.into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound"})),
)
.into_response(),
Ok(None) => ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error updating locale: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+2 -6
View File
@@ -1,3 +1,4 @@
use crate::api::error::ApiError;
use crate::state::AppState;
use axum::{
Json,
@@ -8,7 +9,6 @@ use axum::{
use chrono::{Duration, Utc};
use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info};
const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
@@ -69,11 +69,7 @@ pub async fn reserve_signing_key(
}
Err(e) => {
error!("DB error in reserve_signing_key: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
+74 -300
View File
@@ -1,3 +1,5 @@
use crate::api::EmptyResponse;
use crate::api::error::ApiError;
use crate::auth::BearerAuth;
use crate::auth::totp::{
decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64,
@@ -5,15 +7,14 @@ use crate::auth::totp::{
verify_backup_code, verify_totp_code,
};
use crate::state::{AppState, RateLimitKind};
use crate::types::PlainPassword;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
const ENCRYPTION_VERSION: i32 = 1;
@@ -27,43 +28,26 @@ pub struct CreateTotpSecretResponse {
}
pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth) -> Response {
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
if let Ok(Some(true)) = existing {
return (
StatusCode::CONFLICT,
Json(json!({
"error": "TotpAlreadyEnabled",
"message": "TOTP is already enabled for this account"
})),
)
.into_response();
return ApiError::TotpAlreadyEnabled.into_response();
}
let secret = generate_totp_secret();
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did)
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let handle = match handle {
Ok(Some(h)) => h,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Ok(None) => return ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error fetching handle: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -74,11 +58,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
Ok(qr) => qr,
Err(e) => {
error!("Failed to generate QR code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})),
)
.into_response();
return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response();
}
};
@@ -86,11 +66,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
Ok(enc) => enc,
Err(e) => {
error!("Failed to encrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -105,7 +81,7 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
created_at = NOW(),
last_used = NULL
"#,
auth.0.did,
&auth.0.did,
encrypted_secret,
ENCRYPTION_VERSION
)
@@ -114,16 +90,12 @@ pub async fn create_totp_secret(State(state): State<AppState>, auth: BearerAuth)
if let Err(e) = result {
error!("Failed to store TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret);
info!(did = %auth.0.did, "TOTP secret created (pending verification)");
info!(did = %&auth.0.did, "TOTP secret created (pending verification)");
Json(CreateTotpSecretResponse {
secret: secret_base32,
@@ -153,55 +125,28 @@ pub async fn enable_totp(
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotSetup",
"message": "Please call createTotpSecret first"
})),
)
.into_response();
}
Ok(None) => return ApiError::TotpNotEnabled.into_response(),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if totp_row.verified {
return (
StatusCode::CONFLICT,
Json(json!({
"error": "TotpAlreadyEnabled",
"message": "TOTP is already enabled"
})),
)
.into_response();
return ApiError::TotpAlreadyEnabled.into_response();
}
let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version)
@@ -209,24 +154,13 @@ pub async fn enable_totp(
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let code = input.code.trim();
if !verify_totp_code(&secret, code) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
}
let backup_codes = generate_backup_codes();
@@ -234,39 +168,27 @@ pub async fn enable_totp(
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1",
auth.0.did
&auth.0.did
)
.execute(&mut *tx)
.await
{
error!("Failed to enable TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to clear old backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
for code in &backup_codes {
@@ -274,48 +196,36 @@ pub async fn enable_totp(
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
auth.0.did,
&auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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 ApiError::InternalError(None).into_response();
}
info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
info!(did = %&auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len());
Json(EnableTotpResponse { backup_codes }).into_response()
}
#[derive(Deserialize)]
pub struct DisableTotpInput {
pub password: String,
pub password: PlainPassword,
pub code: String,
}
@@ -333,37 +243,20 @@ pub async fn disable_totp(
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Ok(None) => return ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -372,42 +265,22 @@ pub async fn disable_totp(
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
.unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotEnabled",
"message": "TOTP is not enabled for this account"
})),
)
.into_response();
}
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -420,75 +293,48 @@ pub async fn disable_totp(
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
verify_totp_code(&secret, code)
};
if !code_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
return ApiError::InvalidCode(Some("Invalid verification code".into())).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();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did)
if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", &*&auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to delete TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to delete backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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 ApiError::InternalError(None).into_response();
}
info!(did = %auth.0.did, "TOTP disabled");
info!(did = %&auth.0.did, "TOTP disabled");
(StatusCode::OK, Json(json!({}))).into_response()
EmptyResponse::ok().into_response()
}
#[derive(Serialize)]
@@ -500,7 +346,7 @@ pub struct GetTotpStatusResponse {
}
pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) -> Response {
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did)
let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
@@ -509,17 +355,13 @@ pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) ->
Ok(None) => false,
Err(e) => {
error!("DB error fetching TOTP status: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let backup_count_row = sqlx::query!(
"SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL",
auth.0.did
&auth.0.did
)
.fetch_one(&state.db)
.await;
@@ -536,7 +378,7 @@ pub async fn get_totp_status(State(state): State<AppState>, auth: BearerAuth) ->
#[derive(Deserialize)]
pub struct RegenerateBackupCodesInput {
pub password: String,
pub password: PlainPassword,
pub code: String,
}
@@ -555,37 +397,20 @@ pub async fn regenerate_backup_codes(
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
.await
{
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({
"error": "RateLimitExceeded",
"message": "Too many verification attempts. Please try again in a few minutes."
})),
)
.into_response();
warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded");
return ApiError::RateLimitExceeded(None).into_response();
}
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did)
.fetch_optional(&state.db)
.await;
let password_hash = match user {
Ok(Some(row)) => row.password_hash,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
)
.into_response();
}
Ok(None) => return ApiError::AccountNotFound.into_response(),
Err(e) => {
error!("DB error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -594,42 +419,22 @@ pub async fn regenerate_backup_codes(
.map(|h| bcrypt::verify(&input.password, h).unwrap_or(false))
.unwrap_or(false);
if !password_valid {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidPassword",
"message": "Password is incorrect"
})),
)
.into_response();
return ApiError::InvalidPassword("Password is incorrect".into()).into_response();
}
let totp_row = sqlx::query!(
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
auth.0.did
&auth.0.did
)
.fetch_optional(&state.db)
.await;
let totp_row = match totp_row {
Ok(Some(row)) if row.verified => row,
Ok(Some(_)) | Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "TotpNotEnabled",
"message": "TOTP must be enabled to regenerate backup codes"
})),
)
.into_response();
}
Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(),
Err(e) => {
error!("DB error fetching TOTP: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
@@ -638,24 +443,13 @@ pub async fn regenerate_backup_codes(
Ok(s) => s,
Err(e) => {
error!("Failed to decrypt TOTP secret: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
let code = input.code.trim();
if !verify_totp_code(&secret, code) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "InvalidCode",
"message": "Invalid verification code"
})),
)
.into_response();
return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response();
}
let backup_codes = generate_backup_codes();
@@ -663,24 +457,16 @@ pub async fn regenerate_backup_codes(
Ok(tx) => tx,
Err(e) => {
error!("Failed to begin transaction: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did)
if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did)
.execute(&mut *tx)
.await
{
error!("Failed to clear old backup codes: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
for code in &backup_codes {
@@ -688,41 +474,29 @@ pub async fn regenerate_backup_codes(
Ok(h) => h,
Err(e) => {
error!("Failed to hash backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())",
auth.0.did,
&auth.0.did,
hash
)
.execute(&mut *tx)
.await
{
error!("Failed to store backup code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).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 ApiError::InternalError(None).into_response();
}
info!(did = %auth.0.did, "Backup codes regenerated");
info!(did = %&auth.0.did, "Backup codes regenerated");
Json(RegenerateBackupCodesResponse { backup_codes }).into_response()
}
+73 -55
View File
@@ -1,12 +1,12 @@
use crate::api::error::ApiError;
use crate::api::SuccessResponse;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use tracing::{error, info};
@@ -15,6 +15,43 @@ use crate::state::AppState;
const TRUST_DURATION_DAYS: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceTrustState {
Untrusted,
Trusted,
Expired,
}
impl DeviceTrustState {
pub fn from_timestamps(
trusted_at: Option<DateTime<Utc>>,
trusted_until: Option<DateTime<Utc>>,
) -> Self {
match (trusted_at, trusted_until) {
(Some(_), Some(until)) if until > Utc::now() => Self::Trusted,
(Some(_), Some(_)) => Self::Expired,
_ => Self::Untrusted,
}
}
pub fn is_trusted(&self) -> bool {
matches!(self, Self::Trusted)
}
pub fn is_expired(&self) -> bool {
matches!(self, Self::Expired)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Untrusted => "untrusted",
Self::Trusted => "trusted",
Self::Expired => "expired",
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedDevice {
@@ -24,6 +61,7 @@ pub struct TrustedDevice {
pub trusted_at: Option<DateTime<Utc>>,
pub trusted_until: Option<DateTime<Utc>>,
pub last_seen_at: DateTime<Utc>,
pub trust_state: DeviceTrustState,
}
#[derive(Serialize)]
@@ -39,7 +77,7 @@ pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAut
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()
ORDER BY od.last_seen_at DESC"#,
auth.0.did
&auth.0.did
)
.fetch_all(&state.db)
.await;
@@ -48,24 +86,24 @@ pub async fn list_trusted_devices(State(state): State<AppState>, auth: BearerAut
Ok(rows) => {
let devices = rows
.into_iter()
.map(|row| TrustedDevice {
id: row.id,
user_agent: row.user_agent,
friendly_name: row.friendly_name,
trusted_at: row.trusted_at,
trusted_until: row.trusted_until,
last_seen_at: row.last_seen_at,
.map(|row| {
let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until);
TrustedDevice {
id: row.id,
user_agent: row.user_agent,
friendly_name: row.friendly_name,
trusted_at: row.trusted_at,
trusted_until: row.trusted_until,
last_seen_at: row.last_seen_at,
trust_state,
}
})
.collect();
Json(ListTrustedDevicesResponse { devices }).into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -85,7 +123,7 @@ pub async fn revoke_trusted_device(
r#"SELECT 1 as one FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.id = $2"#,
auth.0.did,
&auth.0.did,
input.device_id
)
.fetch_optional(&state.db)
@@ -94,19 +132,11 @@ pub async fn revoke_trusted_device(
match device_exists {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
)
.into_response();
return ApiError::DeviceNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
@@ -119,16 +149,12 @@ pub async fn revoke_trusted_device(
match result {
Ok(_) => {
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked");
Json(json!({"success": true})).into_response()
info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked");
SuccessResponse::ok().into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
@@ -149,7 +175,7 @@ pub async fn update_trusted_device(
r#"SELECT 1 as one FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE oad.did = $1 AND od.id = $2"#,
auth.0.did,
&auth.0.did,
input.device_id
)
.fetch_optional(&state.db)
@@ -158,19 +184,11 @@ pub async fn update_trusted_device(
match device_exists {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})),
)
.into_response();
return ApiError::DeviceNotFound.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
return ApiError::InternalError(None).into_response();
}
}
@@ -185,22 +203,18 @@ pub async fn update_trusted_device(
match result {
Ok(_) => {
info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated");
Json(json!({"success": true})).into_response()
SuccessResponse::ok().into_response()
}
Err(e) => {
error!("DB error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
ApiError::InternalError(None).into_response()
}
}
}
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
let result = sqlx::query_scalar!(
r#"SELECT trusted_until FROM oauth_device od
pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) -> DeviceTrustState {
let result = sqlx::query!(
r#"SELECT trusted_at, trusted_until FROM oauth_device od
JOIN oauth_account_device oad ON od.id = oad.device_id
WHERE od.id = $1 AND oad.did = $2"#,
device_id,
@@ -210,11 +224,15 @@ pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool
.await;
match result {
Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(),
_ => false,
Ok(Some(row)) => DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until),
_ => DeviceTrustState::Untrusted,
}
}
pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool {
get_device_trust_state(db, device_id, did).await.is_trusted()
}
pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> {
let now = Utc::now();
let trusted_until = now + Duration::days(TRUST_DURATION_DAYS);
+8 -10
View File
@@ -1,6 +1,7 @@
use axum::{Json, extract::State, http::StatusCode};
use crate::api::error::ApiError;
use crate::types::Did;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{info, warn};
use crate::state::AppState;
@@ -16,13 +17,13 @@ pub struct VerifyMigrationEmailInput {
#[serde(rename_all = "camelCase")]
pub struct VerifyMigrationEmailOutput {
pub success: bool,
pub did: String,
pub did: Did,
}
pub async fn verify_migration_email(
State(state): State<AppState>,
Json(input): Json<VerifyMigrationEmailInput>,
) -> Result<Json<VerifyMigrationEmailOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyMigrationEmailOutput>, ApiError> {
let token_input = super::verify_token::VerifyTokenInput {
token: input.token,
identifier: input.email,
@@ -32,7 +33,7 @@ pub async fn verify_migration_email(
Ok(Json(VerifyMigrationEmailOutput {
success: result.success,
did: result.did.clone(),
did: result.did.clone().into(),
}))
}
@@ -51,7 +52,7 @@ pub struct ResendMigrationVerificationOutput {
pub async fn resend_migration_verification(
State(state): State<AppState>,
Json(input): Json<ResendMigrationVerificationInput>,
) -> Result<Json<ResendMigrationVerificationOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<ResendMigrationVerificationOutput>, ApiError> {
let email = input.email.trim().to_lowercase();
let user = sqlx::query!(
@@ -62,10 +63,7 @@ pub async fn resend_migration_verification(
.await
.map_err(|e| {
warn!(error = %e, "Database error during resend verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
ApiError::InternalError(None)
})?;
let user = match user {
+32 -115
View File
@@ -1,10 +1,11 @@
use axum::{Json, extract::State, http::StatusCode};
use crate::api::error::ApiError;
use crate::types::Did;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use crate::auth::verification_token::{
VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature,
VerificationPurpose, normalize_token_input, verify_token_signature,
};
use crate::state::AppState;
@@ -19,7 +20,7 @@ pub struct VerifyTokenInput {
#[serde(rename_all = "camelCase")]
pub struct VerifyTokenOutput {
pub success: bool,
pub did: String,
pub did: Did,
pub purpose: String,
pub channel: String,
}
@@ -27,60 +28,25 @@ pub struct VerifyTokenOutput {
pub async fn verify_token(
State(state): State<AppState>,
Json(input): Json<VerifyTokenInput>,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyTokenOutput>, ApiError> {
verify_token_internal(&state, input).await
}
pub async fn verify_token_internal(
state: &AppState,
input: VerifyTokenInput,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let normalized_token = normalize_token_input(&input.token);
let identifier = input.identifier.trim().to_lowercase();
let token_data = match verify_token_signature(&normalized_token) {
Ok(data) => data,
Err(e) => {
let (status, error, message) = match e {
VerifyError::InvalidFormat => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token is invalid or malformed",
),
VerifyError::UnsupportedVersion => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"This verification token version is not supported",
),
VerifyError::Expired => (
StatusCode::BAD_REQUEST,
"ExpiredToken",
"The verification token has expired. Please request a new one.",
),
VerifyError::InvalidSignature => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token signature is invalid",
),
_ => (
StatusCode::BAD_REQUEST,
"InvalidToken",
"The verification token is not valid",
),
};
warn!(error = ?e, "Token verification failed");
return Err((status, Json(json!({ "error": error, "message": message }))));
}
};
let token_data = verify_token_signature(&normalized_token).map_err(|e| {
warn!(error = ?e, "Token verification failed");
ApiError::from(e)
})?;
let expected_hash = crate::auth::verification_token::hash_identifier(&identifier);
if token_data.identifier_hash != expected_hash {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }),
),
));
return Err(ApiError::IdentifierMismatch);
}
match token_data.purpose {
@@ -103,14 +69,9 @@ async fn handle_migration_verification(
did: &str,
channel: &str,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyTokenOutput>, ApiError> {
if channel != "email" {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }),
),
));
return Err(ApiError::InvalidChannel);
}
let user = sqlx::query!(
@@ -121,26 +82,13 @@ async fn handle_migration_verification(
.await
.map_err(|e| {
warn!(error = %e, "Database error during migration verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
ApiError::InternalError(None)
})?;
let user = user.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
)
})?;
let user = user.ok_or(ApiError::AccountNotFound)?;
if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }),
),
));
return Err(ApiError::IdentifierMismatch);
}
if !user.email_verified {
@@ -152,10 +100,7 @@ async fn handle_migration_verification(
.await
.map_err(|e| {
warn!(error = %e, "Failed to update email_verified status");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to verify email" })),
)
ApiError::InternalError(None)
})?;
}
@@ -163,7 +108,7 @@ async fn handle_migration_verification(
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
did: did.to_string().into(),
purpose: "migration".to_string(),
channel: channel.to_string(),
}))
@@ -174,16 +119,11 @@ async fn handle_channel_update(
did: &str,
channel: &str,
identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_one(&state.db)
.await
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "User not found" })),
)
})?;
.map_err(|_| ApiError::InternalError(None))?;
let update_result = match channel {
"email" => sqlx::query!(
@@ -207,10 +147,7 @@ async fn handle_channel_update(
user_id
).execute(&state.db).await,
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
));
return Err(ApiError::InvalidChannel);
}
};
@@ -221,22 +158,16 @@ async fn handle_channel_update(
.map(|db| db.is_unique_violation())
.unwrap_or(false)
{
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "EmailTaken", "message": "Email already in use" })),
));
return Err(ApiError::EmailTaken);
}
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to update channel" })),
));
return Err(ApiError::InternalError(None));
}
info!(did = %did, channel = %channel, "Channel verified successfully");
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
did: did.to_string().into(),
purpose: "channel_update".to_string(),
channel: channel.to_string(),
}))
@@ -247,7 +178,7 @@ async fn handle_signup_verification(
did: &str,
channel: &str,
_identifier: &str,
) -> Result<Json<VerifyTokenOutput>, (StatusCode, Json<serde_json::Value>)> {
) -> Result<Json<VerifyTokenOutput>, ApiError> {
let user = sqlx::query!(
"SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1",
did
@@ -256,18 +187,10 @@ async fn handle_signup_verification(
.await
.map_err(|e| {
warn!(error = %e, "Database error during signup verification");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Database error" })),
)
ApiError::InternalError(None)
})?;
let user = user.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })),
)
})?;
let user = user.ok_or(ApiError::AccountNotFound)?;
let is_verified = user.email_verified
|| user.discord_verified
@@ -277,7 +200,7 @@ async fn handle_signup_verification(
info!(did = %did, "Account already verified");
return Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
did: did.to_string().into(),
purpose: "signup".to_string(),
channel: channel.to_string(),
}));
@@ -317,26 +240,20 @@ async fn handle_signup_verification(
.await
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })),
));
return Err(ApiError::InvalidChannel);
}
};
update_result.map_err(|e| {
warn!(error = %e, "Failed to update channel verified status");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })),
)
ApiError::InternalError(None)
})?;
info!(did = %did, channel = %channel, "Signup verified successfully");
Ok(Json(VerifyTokenOutput {
success: true,
did: did.to_string(),
did: did.to_string().into(),
purpose: "signup".to_string(),
channel: channel.to_string(),
}))
+7 -25
View File
@@ -1,15 +1,15 @@
use crate::api::error::ApiError;
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::HeaderMap,
response::{IntoResponse, Response},
};
use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
#[derive(Serialize)]
@@ -28,14 +28,7 @@ pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMa
&& let Ok(user) = validate_bearer_token(&state.db, &token).await
&& user.is_oauth
{
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "Forbidden",
"message": "OAuth credentials are not supported for this endpoint"
})),
)
.into_response();
return ApiError::Forbidden.into_response();
}
Json(CheckSignupQueueOutput {
activated: true,
@@ -62,25 +55,14 @@ pub async fn dereference_scope(
headers: HeaderMap,
Json(input): Json<DereferenceScopeInput>,
) -> Response {
let token = match extract_bearer_token_from_header(
let Some(token) = 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();
}
) else {
return ApiError::AuthenticationRequired.into_response();
};
if validate_bearer_token(&state.db, &token).await.is_err() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
return ApiError::AuthenticationFailed(None).into_response();
}
let scope_parts: Vec<&str> = input.scope.split_whitespace().collect();
+195
View File
@@ -1,3 +1,7 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::ops::Deref;
pub const MAX_EMAIL_LENGTH: usize = 254;
pub const MAX_LOCAL_PART_LENGTH: usize = 64;
pub const MAX_DOMAIN_LENGTH: usize = 253;
@@ -8,6 +12,195 @@ pub const MIN_HANDLE_LENGTH: usize = 3;
pub const MAX_HANDLE_LENGTH: usize = 253;
pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ValidatedLocalHandle(String);
impl ValidatedLocalHandle {
pub fn new(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
let validated = validate_short_handle(handle.as_ref())?;
Ok(Self(validated))
}
pub fn new_allow_reserved(handle: impl AsRef<str>) -> Result<Self, HandleValidationError> {
let validated = validate_service_handle(handle.as_ref(), true)?;
Ok(Self(validated))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
}
impl Deref for ValidatedLocalHandle {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl fmt::Display for ValidatedLocalHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for ValidatedLocalHandle {
type Error = HandleValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ValidatedLocalHandle> for String {
fn from(handle: ValidatedLocalHandle) -> Self {
handle.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmailValidationError {
Empty,
TooLong,
MissingAtSign,
EmptyLocalPart,
LocalPartTooLong,
InvalidLocalPart,
EmptyDomain,
DomainTooLong,
MissingDomainDot,
InvalidDomainLabel,
}
impl fmt::Display for EmailValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "Email cannot be empty"),
Self::TooLong => write!(f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH),
Self::MissingAtSign => write!(f, "Email must contain @"),
Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"),
Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"),
Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"),
Self::EmptyDomain => write!(f, "Email domain cannot be empty"),
Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"),
Self::MissingDomainDot => write!(f, "Email domain must contain a dot"),
Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"),
}
}
}
impl std::error::Error for EmailValidationError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ValidatedEmail(String);
impl ValidatedEmail {
pub fn new(email: impl AsRef<str>) -> Result<Self, EmailValidationError> {
let email = email.as_ref().trim();
validate_email_detailed(email)?;
Ok(Self(email.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn local_part(&self) -> &str {
self.0.rsplitn(2, '@').nth(1).unwrap_or("")
}
pub fn domain(&self) -> &str {
self.0.rsplitn(2, '@').next().unwrap_or("")
}
}
impl Deref for ValidatedEmail {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl fmt::Display for ValidatedEmail {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl TryFrom<String> for ValidatedEmail {
type Error = EmailValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<ValidatedEmail> for String {
fn from(email: ValidatedEmail) -> Self {
email.0
}
}
fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> {
if email.is_empty() {
return Err(EmailValidationError::Empty);
}
if email.len() > MAX_EMAIL_LENGTH {
return Err(EmailValidationError::TooLong);
}
let parts: Vec<&str> = email.rsplitn(2, '@').collect();
if parts.len() != 2 {
return Err(EmailValidationError::MissingAtSign);
}
let domain = parts[0];
let local = parts[1];
if local.is_empty() {
return Err(EmailValidationError::EmptyLocalPart);
}
if local.len() > MAX_LOCAL_PART_LENGTH {
return Err(EmailValidationError::LocalPartTooLong);
}
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
return Err(EmailValidationError::InvalidLocalPart);
}
for c in local.chars() {
if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
return Err(EmailValidationError::InvalidLocalPart);
}
}
if domain.is_empty() {
return Err(EmailValidationError::EmptyDomain);
}
if domain.len() > MAX_DOMAIN_LENGTH {
return Err(EmailValidationError::DomainTooLong);
}
if !domain.contains('.') {
return Err(EmailValidationError::MissingDomainDot);
}
for label in domain.split('.') {
if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
return Err(EmailValidationError::InvalidDomainLabel);
}
if label.starts_with('-') || label.ends_with('-') {
return Err(EmailValidationError::InvalidDomainLabel);
}
for c in label.chars() {
if !c.is_ascii_alphanumeric() && c != '-' {
return Err(EmailValidationError::InvalidDomainLabel);
}
}
}
Ok(())
}
#[derive(Debug, PartialEq)]
pub enum HandleValidationError {
Empty,
@@ -50,6 +243,8 @@ impl std::fmt::Display for HandleValidationError {
}
}
impl std::error::Error for HandleValidationError {}
pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> {
validate_service_handle(handle, false)
}
+3 -3
View File
@@ -1,3 +1,4 @@
use crate::api::SuccessResponse;
use crate::state::AppState;
use axum::{
Json,
@@ -5,7 +6,6 @@ use axum::{
response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -25,7 +25,7 @@ pub async fn confirm_channel_verification(
};
match crate::api::server::verify_token_internal(&state, token_input).await {
Ok(output) => Json(json!({"success": output.success})).into_response(),
Err((status, err_json)) => (status, err_json).into_response(),
Ok(_output) => SuccessResponse::ok().into_response(),
Err(e) => e.into_response(),
}
}
+5 -16
View File
@@ -41,26 +41,15 @@ pub struct ResolvedService {
pub did: String,
}
#[derive(Clone)]
pub struct DidResolver {
did_cache: RwLock<HashMap<String, CachedDid>>,
did_doc_cache: RwLock<HashMap<String, CachedDidDocument>>,
did_cache: Arc<RwLock<HashMap<String, CachedDid>>>,
did_doc_cache: Arc<RwLock<HashMap<String, CachedDidDocument>>>,
client: Client,
cache_ttl: Duration,
plc_directory_url: String,
}
impl Clone for DidResolver {
fn clone(&self) -> Self {
Self {
did_cache: RwLock::new(HashMap::new()),
did_doc_cache: RwLock::new(HashMap::new()),
client: self.client.clone(),
cache_ttl: self.cache_ttl,
plc_directory_url: self.plc_directory_url.clone(),
}
}
}
impl DidResolver {
pub fn new() -> Self {
let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS")
@@ -81,8 +70,8 @@ impl DidResolver {
info!("DID resolver initialized");
Self {
did_cache: RwLock::new(HashMap::new()),
did_doc_cache: RwLock::new(HashMap::new()),
did_cache: Arc::new(RwLock::new(HashMap::new())),
did_doc_cache: Arc::new(RwLock::new(HashMap::new())),
client,
cache_ttl: Duration::from_secs(cache_ttl_secs),
plc_directory_url,
+6 -45
View File
@@ -1,15 +1,14 @@
use axum::{
Json,
extract::FromRequestParts,
http::{StatusCode, header::AUTHORIZATION, request::Parts},
http::{header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Response},
};
use serde_json::json;
use super::{
AuthenticatedUser, TokenValidationError, validate_bearer_token_cached,
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
};
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::util::build_full_url;
@@ -28,45 +27,7 @@ pub enum AuthError {
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, error, message) = match self {
AuthError::MissingToken => (
StatusCode::UNAUTHORIZED,
"AuthenticationRequired",
"Authorization header is required",
),
AuthError::InvalidFormat => (
StatusCode::UNAUTHORIZED,
"InvalidToken",
"Invalid authorization header format",
),
AuthError::AuthenticationFailed => (
StatusCode::UNAUTHORIZED,
"InvalidToken",
"Token could not be verified",
),
AuthError::TokenExpired => (
StatusCode::UNAUTHORIZED,
"ExpiredToken",
"Token has expired",
),
AuthError::AccountDeactivated => (
StatusCode::UNAUTHORIZED,
"AccountDeactivated",
"Account is deactivated",
),
AuthError::AccountTakedown => (
StatusCode::UNAUTHORIZED,
"AccountTakedown",
"Account has been taken down",
),
AuthError::AdminRequired => (
StatusCode::FORBIDDEN,
"AdminRequired",
"This action requires admin privileges",
),
};
(status, Json(json!({ "error": error, "message": message }))).into_response()
ApiError::from(self).into_response()
}
}
@@ -185,7 +146,7 @@ impl FromRequestParts<AppState> for BearerAuth {
Err(_) => Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
@@ -239,7 +200,7 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
} else {
match validate_bearer_token_cached_allow_deactivated(
&state.db,
&state.cache,
state.cache.as_ref(),
&extracted.token,
)
.await
@@ -301,7 +262,7 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
Err(_) => return Err(AuthError::AuthenticationFailed),
}
} else {
match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await {
match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await {
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
+42 -22
View File
@@ -1,9 +1,10 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use crate::types::Did;
use crate::AccountStatus;
use crate::cache::Cache;
use crate::oauth::scopes::ScopePermissions;
@@ -66,13 +67,13 @@ impl fmt::Display for TokenValidationError {
}
pub struct AuthenticatedUser {
pub did: String,
pub did: Did,
pub key_bytes: Option<Vec<u8>>,
pub is_oauth: bool,
pub is_admin: bool,
pub is_takendown: bool,
pub status: AccountStatus,
pub scope: Option<String>,
pub controller_did: Option<String>,
pub controller_did: Option<Did>,
}
impl AuthenticatedUser {
@@ -87,6 +88,10 @@ impl AuthenticatedUser {
}
ScopePermissions::from_scope_string(self.scope.as_deref())
}
pub fn is_takendown(&self) -> bool {
self.status.is_takendown()
}
}
pub async fn validate_bearer_token(
@@ -105,7 +110,7 @@ pub async fn validate_bearer_token_allow_deactivated(
pub async fn validate_bearer_token_cached(
db: &PgPool,
cache: &Arc<dyn Cache>,
cache: &dyn Cache,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, false, false).await
@@ -113,7 +118,7 @@ pub async fn validate_bearer_token_cached(
pub async fn validate_bearer_token_cached_allow_deactivated(
db: &PgPool,
cache: &Arc<dyn Cache>,
cache: &dyn Cache,
token: &str,
) -> Result<AuthenticatedUser, TokenValidationError> {
validate_bearer_token_with_options_internal(db, Some(cache), token, true, false).await
@@ -135,7 +140,7 @@ pub async fn validate_bearer_token_allow_takendown(
async fn validate_bearer_token_with_options_internal(
db: &PgPool,
cache: Option<&Arc<dyn Cache>>,
cache: Option<&dyn Cache>,
token: &str,
allow_deactivated: bool,
allow_takendown: bool,
@@ -324,13 +329,21 @@ async fn validate_bearer_token_with_options_internal(
}
if session_valid {
let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone());
let controller_did = token_data
.claims
.act
.as_ref()
.map(|a| Did::new_unchecked(a.sub.clone()));
let status = AccountStatus::from_db_fields(
takedown_ref.as_deref(),
deactivated_at,
);
return Ok(AuthenticatedUser {
did: did.clone(),
did: Did::new_unchecked(did.clone()),
key_bytes: Some(decrypted_key),
is_oauth: false,
is_admin,
is_takendown: takedown_ref.is_some(),
status,
scope: token_data.claims.scope.clone(),
controller_did,
});
@@ -359,12 +372,16 @@ async fn validate_bearer_token_with_options_internal(
.ok()
.flatten()
{
if !allow_deactivated && oauth_token.deactivated_at.is_some() {
let status = AccountStatus::from_db_fields(
oauth_token.takedown_ref.as_deref(),
oauth_token.deactivated_at,
);
if !allow_deactivated && status.is_deactivated() {
return Err(TokenValidationError::AccountDeactivated);
}
let is_takendown = oauth_token.takedown_ref.is_some();
if !allow_takendown && is_takendown {
if !allow_takendown && status.is_takendown() {
return Err(TokenValidationError::AccountTakedown);
}
@@ -378,13 +395,13 @@ async fn validate_bearer_token_with_options_internal(
None
};
return Ok(AuthenticatedUser {
did: oauth_token.did,
did: Did::new_unchecked(oauth_token.did),
key_bytes,
is_oauth: true,
is_admin: oauth_token.is_admin,
is_takendown,
status,
scope: oauth_info.scope,
controller_did: oauth_info.controller_did,
controller_did: oauth_info.controller_did.map(Did::new_unchecked),
});
} else {
return Err(TokenValidationError::TokenExpired);
@@ -394,7 +411,7 @@ async fn validate_bearer_token_with_options_internal(
Err(TokenValidationError::AuthenticationFailed)
}
pub async fn invalidate_auth_cache(cache: &Arc<dyn Cache>, did: &str) {
pub async fn invalidate_auth_cache(cache: &dyn Cache, did: &str) {
let key_cache_key = format!("auth:key:{}", did);
let status_cache_key = format!("auth:status:{}", did);
let _ = cache.delete(&key_cache_key).await;
@@ -442,11 +459,14 @@ pub async fn validate_token_with_dpop(
let Some(user_info) = user_info else {
return Err(TokenValidationError::AuthenticationFailed);
};
if !allow_deactivated && user_info.deactivated_at.is_some() {
let status = AccountStatus::from_db_fields(
user_info.takedown_ref.as_deref(),
user_info.deactivated_at,
);
if !allow_deactivated && status.is_deactivated() {
return Err(TokenValidationError::AccountDeactivated);
}
let is_takendown = user_info.takedown_ref.is_some();
if is_takendown {
if status.is_takendown() {
return Err(TokenValidationError::AccountTakedown);
}
let key_bytes = if let (Some(kb), Some(ev)) =
@@ -457,11 +477,11 @@ pub async fn validate_token_with_dpop(
None
};
Ok(AuthenticatedUser {
did: result.did,
did: Did::new_unchecked(result.did),
key_bytes,
is_oauth: true,
is_admin: user_info.is_admin,
is_takendown,
status,
scope: result.scope,
controller_did: None,
})
+16 -52
View File
@@ -1,9 +1,8 @@
#![allow(clippy::result_large_err)]
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use crate::api::error::ApiError;
use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
@@ -28,16 +27,9 @@ pub fn check_repo_scope(
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_repo(action, collection).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
permissions
.assert_repo(action, collection)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
@@ -46,16 +38,9 @@ pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Resu
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_blob(mime).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
permissions
.assert_blob(mime)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
pub fn check_rpc_scope(
@@ -69,16 +54,9 @@ pub fn check_rpc_scope(
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_rpc(aud, lxm).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
permissions
.assert_rpc(aud, lxm)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
pub fn check_account_scope(
@@ -92,16 +70,9 @@ pub fn check_account_scope(
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_account(attr, action).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
permissions
.assert_account(attr, action)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
pub fn check_identity_scope(
@@ -114,14 +85,7 @@ pub fn check_identity_scope(
}
let permissions = ScopePermissions::from_scope_string(scope);
permissions.assert_identity(attr).map_err(|e| {
(
StatusCode::FORBIDDEN,
axum::Json(json!({
"error": "InsufficientScope",
"message": e.to_string()
})),
)
.into_response()
})
permissions
.assert_identity(attr)
.map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response())
}
+10 -10
View File
@@ -257,7 +257,7 @@ pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result<Uuid, sqlx::Er
pub struct UserCommsPrefs {
pub channel: CommsChannel,
pub email: Option<String>,
pub handle: String,
pub handle: crate::types::Handle,
pub locale: String,
}
@@ -282,7 +282,7 @@ pub async fn get_user_comms_prefs(
Ok(UserCommsPrefs {
channel: row.channel,
email: row.email,
handle: row.handle,
handle: row.handle.into(),
locale: row.preferred_locale.unwrap_or_else(|| "en".to_string()),
})
}
@@ -305,7 +305,7 @@ pub async fn enqueue_welcome(
user_id,
prefs.channel,
super::types::CommsType::Welcome,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -332,7 +332,7 @@ pub async fn enqueue_password_reset(
user_id,
prefs.channel,
super::types::CommsType::PasswordReset,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -388,7 +388,7 @@ pub async fn enqueue_email_update_token(
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let current_email = prefs.email.clone().unwrap_or_default();
let current_email = prefs.email.unwrap_or_default();
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
let verify_link = format!(
"https://{}/app/verify?type=email-update&token={}",
@@ -437,7 +437,7 @@ pub async fn enqueue_account_deletion(
user_id,
prefs.channel,
super::types::CommsType::AccountDeletion,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -464,7 +464,7 @@ pub async fn enqueue_plc_operation(
user_id,
prefs.channel,
super::types::CommsType::PlcOperation,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -491,7 +491,7 @@ pub async fn enqueue_2fa_code(
user_id,
prefs.channel,
super::types::CommsType::TwoFactorCode,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -518,7 +518,7 @@ pub async fn enqueue_passkey_recovery(
user_id,
prefs.channel,
super::types::CommsType::PasskeyRecovery,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
@@ -665,7 +665,7 @@ pub async fn queue_legacy_login_notification(
user_id,
channel,
super::types::CommsType::LegacyLoginAlert,
prefs.email.clone().unwrap_or_default(),
prefs.email.unwrap_or_default(),
Some(subject),
body,
),
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::types::Handle;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
@@ -18,7 +19,7 @@ pub struct DelegationGrant {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegatedAccountInfo {
pub did: String,
pub handle: String,
pub handle: Handle,
pub granted_scopes: String,
pub granted_at: DateTime<Utc>,
}
@@ -26,7 +27,7 @@ pub struct DelegatedAccountInfo {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControllerInfo {
pub did: String,
pub handle: String,
pub handle: Handle,
pub granted_scopes: String,
pub granted_at: DateTime<Utc>,
pub is_active: bool,
+6 -2
View File
@@ -19,10 +19,13 @@ pub mod scheduled;
pub mod state;
pub mod storage;
pub mod sync;
pub mod types;
pub mod util;
pub mod validation;
use api::proxy::XrpcProxyLayer;
pub use sync::util::AccountStatus;
pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey};
use axum::{
Json, Router,
extract::DefaultBodyLimit,
@@ -33,7 +36,7 @@ use axum::{
use http::StatusCode;
use serde_json::json;
use state::AppState;
use tower::{Layer, ServiceBuilder};
use tower::ServiceBuilder;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::{ServeDir, ServeFile};
@@ -571,6 +574,7 @@ pub fn app(state: AppState) -> Router {
let router = Router::new()
.nest_service("/xrpc", xrpc_service)
.nest("/oauth", oauth_router)
.nest("/.well-known", well_known_router)
.route("/metrics", get(metrics::metrics_handler))
.route("/health", get(api::server::health))
.route("/robots.txt", get(api::server::robots_txt))
@@ -606,7 +610,7 @@ pub fn app(state: AppState) -> Router {
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
router
return router
.route_service("/", ServeFile::new(&homepage_file))
.nest("/app", spa_router)
.fallback_service(serve_dir);
+6 -4
View File
@@ -35,10 +35,12 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let backfill_db = state.db.clone();
let backfill_block_store = state.block_store.clone();
tokio::spawn(async move {
backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()).await;
backfill_repo_rev(&backfill_db, backfill_block_store.clone()).await;
backfill_user_blocks(&backfill_db, backfill_block_store.clone()).await;
backfill_record_blobs(&backfill_db, backfill_block_store).await;
tokio::join!(
backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()),
backfill_repo_rev(&backfill_db, backfill_block_store.clone()),
backfill_user_blocks(&backfill_db, backfill_block_store.clone()),
backfill_record_blobs(&backfill_db, backfill_block_store),
);
});
let mut comms_service = CommsService::new(state.db.clone());
+3 -2
View File
@@ -1,10 +1,11 @@
use super::super::{DeviceData, OAuthError};
use crate::types::Handle;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
pub struct DeviceAccountRow {
pub did: String,
pub handle: String,
pub handle: Handle,
pub email: Option<String>,
pub last_used_at: DateTime<Utc>,
}
@@ -116,7 +117,7 @@ pub async fn get_device_accounts(
.into_iter()
.map(|r| DeviceAccountRow {
did: r.did,
handle: r.handle,
handle: r.handle.into(),
email: r.email,
last_used_at: r.last_used_at,
})
+7 -6
View File
@@ -16,18 +16,19 @@ 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,
mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did,
update_authorization_request, update_request_scope,
get_authorization_request_with_state, mark_request_authenticated, set_authorization_did,
set_controller_did, set_request_did, update_authorization_request, update_request_scope,
};
pub use scope_preference::{
ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent,
upsert_scope_preferences,
};
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_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token,
RefreshTokenLookup, 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_previous_refresh_token, get_token_by_refresh_token,
list_tokens_for_user, lookup_refresh_token, revoke_tokens_for_client,
revoke_tokens_for_controller, rotate_token,
};
pub use two_factor::{
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+14 -1
View File
@@ -1,7 +1,20 @@
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
use super::super::{AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
use super::helpers::{from_json, to_json};
use sqlx::PgPool;
pub async fn get_authorization_request_with_state(
pool: &PgPool,
request_id: &str,
) -> Result<Option<(RequestData, AuthFlowState)>, OAuthError> {
match get_authorization_request(pool, request_id).await? {
Some(data) => {
let state = AuthFlowState::from_request_data(&data);
Ok(Some((data, state)))
}
None => Ok(None),
}
}
pub async fn create_authorization_request(
pool: &PgPool,
request_id: &str,
+47 -1
View File
@@ -1,8 +1,54 @@
use super::super::{OAuthError, TokenData};
use super::super::{OAuthError, RefreshTokenState, TokenData};
use super::helpers::{from_json, to_json};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
pub enum RefreshTokenLookup {
Valid { db_id: i32, token_data: TokenData },
InGracePeriod { db_id: i32, token_data: TokenData, rotated_at: DateTime<Utc> },
Used { original_token_id: i32 },
Expired { db_id: i32 },
NotFound,
}
impl RefreshTokenLookup {
pub fn state(&self) -> RefreshTokenState {
match self {
RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid,
RefreshTokenLookup::InGracePeriod { rotated_at, .. } => {
RefreshTokenState::InGracePeriod { rotated_at: *rotated_at }
}
RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() },
RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired,
RefreshTokenLookup::NotFound => RefreshTokenState::Revoked,
}
}
}
pub async fn lookup_refresh_token(
pool: &PgPool,
refresh_token: &str,
) -> Result<RefreshTokenLookup, OAuthError> {
if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? {
if let Some((db_id, token_data)) = get_token_by_previous_refresh_token(pool, refresh_token).await? {
let rotated_at = token_data.updated_at;
return Ok(RefreshTokenLookup::InGracePeriod { db_id, token_data, rotated_at });
}
return Ok(RefreshTokenLookup::Used { original_token_id: token_id });
}
match get_token_by_refresh_token(pool, refresh_token).await? {
Some((db_id, token_data)) => {
if token_data.expires_at < Utc::now() {
Ok(RefreshTokenLookup::Expired { db_id })
} else {
Ok(RefreshTokenLookup::Valid { db_id, token_data })
}
}
None => Ok(RefreshTokenLookup::NotFound),
}
}
pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthError> {
let client_auth_json = to_json(&data.client_auth)?;
let parameters_json = to_json(&data.parameters)?;
+5 -4
View File
@@ -5,14 +5,15 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::OAuthError;
use crate::types::{DPoPProofId, JwkThumbprint};
const DPOP_NONCE_VALIDITY_SECS: i64 = 300;
const DPOP_MAX_AGE_SECS: i64 = 300;
#[derive(Debug, Clone)]
pub struct DPoPVerifyResult {
pub jkt: String,
pub jti: String,
pub jkt: JwkThumbprint,
pub jti: DPoPProofId,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -179,8 +180,8 @@ impl DPoPVerifier {
)?;
let jkt = compute_jwk_thumbprint(&header.jwk)?;
Ok(DPoPVerifyResult {
jkt,
jti: payload.jti.clone(),
jkt: jkt.into(),
jti: payload.jti.clone().into(),
})
}
}
+87 -93
View File
@@ -1,8 +1,10 @@
use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code};
use crate::oauth::{
Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db,
AuthFlowState, Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache,
db,
};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Handle, PlainPassword};
use axum::{
Json,
extract::{Query, State},
@@ -31,6 +33,38 @@ fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
))
}
fn json_error(status: StatusCode, error: &str, description: &str) -> Response {
(
status,
Json(serde_json::json!({
"error": error,
"error_description": description
})),
)
.into_response()
}
fn validate_auth_flow_state(
flow_state: &AuthFlowState,
require_authenticated: bool,
) -> Option<Response> {
if flow_state.is_expired() {
return Some(json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired",
));
}
if require_authenticated && flow_state.is_pending() {
return Some(json_error(
StatusCode::FORBIDDEN,
"access_denied",
"Not authenticated",
));
}
None
}
fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get("cookie")
@@ -97,7 +131,7 @@ pub struct AuthorizeResponse {
pub struct AuthorizeSubmit {
pub request_uri: String,
pub username: String,
pub password: String,
pub password: PlainPassword,
#[serde(default)]
pub remember_device: bool,
}
@@ -298,7 +332,7 @@ pub async fn authorize_get_json(
#[derive(Debug, Serialize)]
pub struct AccountInfo {
pub did: String,
pub handle: String,
pub handle: Handle,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
@@ -1155,53 +1189,33 @@ pub async fn consent_get(
State(state): State<AppState>,
Query(query): Query<ConsentQuery>,
) -> Response {
let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri"
})),
)
.into_response();
let (request_data, flow_state) =
match db::get_authorization_request_with_state(&state.db, &query.request_uri).await {
Ok(Some(result)) => result,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid or expired request_uri",
);
}
Err(e) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
&format!("Database error: {:?}", e),
);
}
};
if let Some(err_response) = validate_auth_flow_state(&flow_state, true) {
if flow_state.is_expired() {
let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &query.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired"
})),
)
.into_response();
return err_response;
}
let did = match &request_data.did {
Some(d) => d.clone(),
None => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Not authenticated"
})),
)
.into_response();
}
};
let did = flow_state.did().unwrap().to_string();
let client_cache = ClientMetadataCache::new(3600);
let client_metadata = client_cache
.get(&request_data.parameters.client_id)
@@ -1334,53 +1348,33 @@ pub async fn consent_post(
form.approved_scopes,
form.remember
);
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid or expired request_uri"
})),
)
.into_response();
let (request_data, flow_state) =
match db::get_authorization_request_with_state(&state.db, &form.request_uri).await {
Ok(Some(result)) => result,
Ok(None) => {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Invalid or expired request_uri",
);
}
Err(e) => {
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
&format!("Database error: {:?}", e),
);
}
};
if let Some(err_response) = validate_auth_flow_state(&flow_state, true) {
if flow_state.is_expired() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Authorization request has expired"
})),
)
.into_response();
return err_response;
}
let did = match &request_data.did {
Some(d) => d.clone(),
None => {
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "access_denied",
"error_description": "Not authenticated"
})),
)
.into_response();
}
};
let did = flow_state.did().unwrap().to_string();
let original_scope_str = request_data
.parameters
.scope
+2 -1
View File
@@ -1,6 +1,7 @@
use crate::delegation;
use crate::oauth::db;
use crate::state::{AppState, RateLimitKind};
use crate::types::PlainPassword;
use crate::util::extract_client_ip;
use axum::{
Json,
@@ -15,7 +16,7 @@ pub struct DelegationAuthSubmit {
pub request_uri: String,
pub delegated_did: Option<String>,
pub controller_did: String,
pub password: String,
pub password: PlainPassword,
#[serde(default)]
pub remember_device: bool,
}
+66 -60
View File
@@ -1,11 +1,11 @@
use super::helpers::{create_access_token_with_delegation, verify_pkce};
use super::types::{TokenRequest, TokenResponse};
use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest};
use crate::config::AuthConfig;
use crate::delegation;
use crate::oauth::{
ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
AuthFlowState, ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
client::{ClientMetadataCache, verify_client_auth},
db,
db::{self, RefreshTokenLookup},
dpop::DPoPVerifier,
};
use crate::state::AppState;
@@ -20,35 +20,41 @@ const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14;
pub async fn handle_authorization_code_grant(
state: AppState,
_headers: HeaderMap,
request: TokenRequest,
request: ValidatedTokenRequest,
dpop_proof: Option<String>,
) -> Result<(HeaderMap, Json<TokenResponse>), 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 (code, code_verifier, redirect_uri) = match request.grant {
TokenGrant::AuthorizationCode { code, code_verifier, redirect_uri } => {
(code, code_verifier, redirect_uri)
}
_ => return Err(OAuthError::InvalidRequest("Expected authorization_code grant".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() {
let flow_state = AuthFlowState::from_request_data(&auth_request);
if flow_state.is_expired() {
return Err(OAuthError::InvalidGrant(
"Authorization code has expired".to_string(),
));
}
if let Some(request_client_id) = &request.client_id
if !flow_state.can_exchange() {
return Err(OAuthError::InvalidGrant(
"Authorization not completed".to_string(),
));
}
if let Some(request_client_id) = &request.client_auth.client_id
&& 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 did = flow_state.did().unwrap().to_string();
let client_metadata_cache = ClientMetadataCache::new(3600);
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
let client_auth = if let (Some(assertion), Some(assertion_type)) =
(&request.client_assertion, &request.client_assertion_type)
(&request.client_auth.client_assertion, &request.client_auth.client_assertion_type)
{
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidClient(
@@ -58,7 +64,7 @@ pub async fn handle_authorization_code_grant(
ClientAuth::PrivateKeyJwt {
client_assertion: assertion.clone(),
}
} else if let Some(secret) = &request.client_secret {
} else if let Some(secret) = &request.client_auth.client_secret {
ClientAuth::SecretPost {
client_secret: secret.clone(),
}
@@ -67,8 +73,8 @@ pub async fn handle_authorization_code_grant(
};
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
if let Some(redirect_uri) = &request.redirect_uri
&& redirect_uri != &auth_request.parameters.redirect_uri
if let Some(req_redirect_uri) = &redirect_uri
&& req_redirect_uri != &auth_request.parameters.redirect_uri
{
return Err(OAuthError::InvalidGrant(
"redirect_uri mismatch".to_string(),
@@ -87,13 +93,13 @@ pub async fn handle_authorization_code_grant(
));
}
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt
&& &result.jkt != expected_jkt
&& result.jkt.as_str() != expected_jkt
{
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
Some(result.jkt)
Some(result.jkt.as_str().to_string())
} else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() {
return Err(OAuthError::UseDpopNonce(
crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes())
@@ -187,23 +193,30 @@ pub async fn handle_authorization_code_grant(
pub async fn handle_refresh_token_grant(
state: AppState,
_headers: HeaderMap,
request: TokenRequest,
request: ValidatedTokenRequest,
dpop_proof: Option<String>,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let refresh_token_str = request
.refresh_token
.ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?;
let refresh_token_str = match request.grant {
TokenGrant::RefreshToken { refresh_token } => refresh_token,
_ => return Err(OAuthError::InvalidRequest("Expected refresh_token grant".to_string())),
};
let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())];
tracing::info!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
refresh_token_prefix = %token_prefix,
has_dpop = dpop_proof.is_some(),
"Refresh token grant requested"
);
if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? {
if let Some((_db_id, token_data)) =
db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await?
{
let lookup = db::lookup_refresh_token(&state.db, &refresh_token_str).await?;
let token_state = lookup.state();
tracing::debug!(state = %token_state, "Refresh token state");
let (db_id, token_data) = match lookup {
RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data),
RefreshTokenLookup::InGracePeriod { db_id: _, token_data, rotated_at } => {
tracing::info!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
refresh_token_prefix = %token_prefix,
rotated_at = %rotated_at,
"Refresh token reuse within grace period, returning existing tokens"
);
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
@@ -230,35 +243,28 @@ pub async fn handle_refresh_token_grant(
}),
));
}
tracing::warn!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token reuse detected, revoking token family"
);
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(|| {
RefreshTokenLookup::Used { original_token_id } => {
tracing::warn!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token not found in database"
refresh_token_prefix = %token_prefix,
"Refresh token reuse detected, revoking token family"
);
OAuthError::InvalidGrant("Invalid refresh token".to_string())
})?;
if token_data.expires_at < Utc::now() {
tracing::warn!(
did = %token_data.did,
expired_at = %token_data.expires_at,
"Refresh token has expired"
);
db::delete_token_family(&state.db, db_id).await?;
return Err(OAuthError::InvalidGrant(
"Refresh token has expired".to_string(),
));
}
db::delete_token_family(&state.db, original_token_id).await?;
return Err(OAuthError::InvalidGrant(
"Refresh token reuse detected, token family revoked".to_string(),
));
}
RefreshTokenLookup::Expired { db_id } => {
tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token has expired");
db::delete_token_family(&state.db, db_id).await?;
return Err(OAuthError::InvalidGrant(
"Refresh token has expired".to_string(),
));
}
RefreshTokenLookup::NotFound => {
tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found");
return Err(OAuthError::InvalidGrant("Invalid refresh token".to_string()));
}
};
let dpop_jkt = if let Some(proof) = &dpop_proof {
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
@@ -272,13 +278,13 @@ pub async fn handle_refresh_token_grant(
));
}
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt
&& &result.jkt != expected_jkt
&& result.jkt.as_str() != expected_jkt
{
return Err(OAuthError::InvalidDpopProof(
"DPoP key binding mismatch".to_string(),
));
}
Some(result.jkt)
Some(result.jkt.as_str().to_string())
} else if token_data.parameters.dpop_jkt.is_some() {
return Err(OAuthError::InvalidRequest(
"DPoP proof required".to_string(),
+8 -9
View File
@@ -13,7 +13,7 @@ pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify
pub use introspect::{
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
};
pub use types::{TokenRequest, TokenResponse};
pub use types::{ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for")
@@ -65,14 +65,13 @@ pub async fn token_endpoint(
.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
let validated = request.validate()?;
match validated.grant {
TokenGrant::AuthorizationCode { .. } => {
handle_authorization_code_grant(state, headers, validated, dpop_proof).await
}
TokenGrant::RefreshToken { .. } => {
handle_refresh_token_grant(state, headers, validated, 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
))),
}
}
+114 -1
View File
@@ -1,8 +1,57 @@
use crate::oauth::OAuthError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantType {
AuthorizationCode,
RefreshToken,
Unsupported(String),
}
impl GrantType {
pub fn as_str(&self) -> &str {
match self {
Self::AuthorizationCode => "authorization_code",
Self::RefreshToken => "refresh_token",
Self::Unsupported(s) => s,
}
}
}
impl std::str::FromStr for GrantType {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"authorization_code" => Self::AuthorizationCode,
"refresh_token" => Self::RefreshToken,
other => Self::Unsupported(other.to_string()),
})
}
}
impl<'de> Deserialize<'de> for GrantType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(s.parse().unwrap())
}
}
impl Serialize for GrantType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
#[derive(Debug, Deserialize)]
pub struct TokenRequest {
pub grant_type: String,
pub grant_type: GrantType,
#[serde(default)]
pub code: Option<String>,
#[serde(default)]
@@ -21,6 +70,70 @@ pub struct TokenRequest {
pub client_assertion_type: Option<String>,
}
#[derive(Debug, Clone)]
pub enum TokenGrant {
AuthorizationCode {
code: String,
code_verifier: String,
redirect_uri: Option<String>,
},
RefreshToken {
refresh_token: String,
},
}
#[derive(Debug, Clone, Default)]
pub struct ClientAuthParams {
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub client_assertion: Option<String>,
pub client_assertion_type: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ValidatedTokenRequest {
pub grant: TokenGrant,
pub client_auth: ClientAuthParams,
}
impl TokenRequest {
pub fn validate(self) -> Result<ValidatedTokenRequest, OAuthError> {
let grant = match self.grant_type {
GrantType::AuthorizationCode => {
let code = self.code.ok_or_else(|| {
OAuthError::InvalidRequest("code is required for authorization_code grant".to_string())
})?;
let code_verifier = self.code_verifier.ok_or_else(|| {
OAuthError::InvalidRequest("code_verifier is required for authorization_code grant".to_string())
})?;
TokenGrant::AuthorizationCode {
code,
code_verifier,
redirect_uri: self.redirect_uri,
}
}
GrantType::RefreshToken => {
let refresh_token = self.refresh_token.ok_or_else(|| {
OAuthError::InvalidRequest("refresh_token is required for refresh_token grant".to_string())
})?;
TokenGrant::RefreshToken { refresh_token }
}
GrantType::Unsupported(grant_type) => {
return Err(OAuthError::UnsupportedGrantType(grant_type));
}
};
let client_auth = ClientAuthParams {
client_id: self.client_id,
client_secret: self.client_secret,
client_assertion: self.client_assertion,
client_assertion_type: self.client_assertion_type,
};
Ok(ValidatedTokenRequest { grant, client_auth })
}
}
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
+7 -10
View File
@@ -140,16 +140,13 @@ impl AccountAction {
}
fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
let mut params: HashMap<String, Vec<String>> = HashMap::new();
for part in query.split('&') {
if let Some((key, value)) = part.split_once('=') {
params
.entry(key.to_string())
.or_default()
.push(value.to_string());
}
}
params
query
.split('&')
.filter_map(|part| part.split_once('='))
.fold(HashMap::new(), |mut acc, (key, value)| {
acc.entry(key.to_string()).or_default().push(value.to_string());
acc
})
}
pub fn parse_scope(scope: &str) -> ParsedScope {
+279
View File
@@ -245,3 +245,282 @@ pub struct JwkPublicKey {
pub struct Jwks {
pub keys: Vec<JwkPublicKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthFlowState {
Pending,
Authenticated { did: String, device_id: Option<String> },
Authorized { did: String, device_id: Option<String>, code: String },
Expired,
}
impl AuthFlowState {
pub fn from_request_data(data: &RequestData) -> Self {
if data.expires_at < chrono::Utc::now() {
return AuthFlowState::Expired;
}
match (&data.did, &data.code) {
(Some(did), Some(code)) => AuthFlowState::Authorized {
did: did.clone(),
device_id: data.device_id.clone(),
code: code.clone(),
},
(Some(did), None) => AuthFlowState::Authenticated {
did: did.clone(),
device_id: data.device_id.clone(),
},
(None, _) => AuthFlowState::Pending,
}
}
pub fn is_pending(&self) -> bool {
matches!(self, AuthFlowState::Pending)
}
pub fn is_authenticated(&self) -> bool {
matches!(self, AuthFlowState::Authenticated { .. })
}
pub fn is_authorized(&self) -> bool {
matches!(self, AuthFlowState::Authorized { .. })
}
pub fn is_expired(&self) -> bool {
matches!(self, AuthFlowState::Expired)
}
pub fn can_authenticate(&self) -> bool {
matches!(self, AuthFlowState::Pending)
}
pub fn can_authorize(&self) -> bool {
matches!(self, AuthFlowState::Authenticated { .. })
}
pub fn can_exchange(&self) -> bool {
matches!(self, AuthFlowState::Authorized { .. })
}
pub fn did(&self) -> Option<&str> {
match self {
AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => {
Some(did)
}
_ => None,
}
}
pub fn code(&self) -> Option<&str> {
match self {
AuthFlowState::Authorized { code, .. } => Some(code),
_ => None,
}
}
}
impl std::fmt::Display for AuthFlowState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthFlowState::Pending => write!(f, "pending"),
AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did),
AuthFlowState::Authorized { did, code, .. } => {
write!(f, "authorized ({}, code={}...)", did, &code[..8.min(code.len())])
}
AuthFlowState::Expired => write!(f, "expired"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RefreshTokenState {
Valid,
Used { at: chrono::DateTime<chrono::Utc> },
InGracePeriod { rotated_at: chrono::DateTime<chrono::Utc> },
Expired,
Revoked,
}
impl RefreshTokenState {
pub fn is_valid(&self) -> bool {
matches!(self, RefreshTokenState::Valid)
}
pub fn is_usable(&self) -> bool {
matches!(
self,
RefreshTokenState::Valid | RefreshTokenState::InGracePeriod { .. }
)
}
pub fn is_used(&self) -> bool {
matches!(self, RefreshTokenState::Used { .. })
}
pub fn is_in_grace_period(&self) -> bool {
matches!(self, RefreshTokenState::InGracePeriod { .. })
}
pub fn is_expired(&self) -> bool {
matches!(self, RefreshTokenState::Expired)
}
pub fn is_revoked(&self) -> bool {
matches!(self, RefreshTokenState::Revoked)
}
}
impl std::fmt::Display for RefreshTokenState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RefreshTokenState::Valid => write!(f, "valid"),
RefreshTokenState::Used { at } => write!(f, "used ({})", at),
RefreshTokenState::InGracePeriod { rotated_at } => {
write!(f, "grace period (rotated {})", rotated_at)
}
RefreshTokenState::Expired => write!(f, "expired"),
RefreshTokenState::Revoked => write!(f, "revoked"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
fn make_request_data(
did: Option<String>,
code: Option<String>,
expires_in: Duration,
) -> RequestData {
RequestData {
client_id: "test-client".into(),
client_auth: None,
parameters: AuthorizationRequestParameters {
response_type: "code".into(),
client_id: "test-client".into(),
redirect_uri: "https://example.com/callback".into(),
scope: Some("atproto".into()),
state: None,
code_challenge: "test".into(),
code_challenge_method: "S256".into(),
response_mode: None,
login_hint: None,
dpop_jkt: None,
extra: None,
},
expires_at: Utc::now() + expires_in,
did,
device_id: None,
code,
controller_did: None,
}
}
#[test]
fn test_auth_flow_state_pending() {
let data = make_request_data(None, None, Duration::minutes(5));
let state = AuthFlowState::from_request_data(&data);
assert!(state.is_pending());
assert!(!state.is_authenticated());
assert!(!state.is_authorized());
assert!(!state.is_expired());
assert!(state.can_authenticate());
assert!(!state.can_authorize());
assert!(!state.can_exchange());
assert!(state.did().is_none());
assert!(state.code().is_none());
}
#[test]
fn test_auth_flow_state_authenticated() {
let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5));
let state = AuthFlowState::from_request_data(&data);
assert!(!state.is_pending());
assert!(state.is_authenticated());
assert!(!state.is_authorized());
assert!(!state.is_expired());
assert!(!state.can_authenticate());
assert!(state.can_authorize());
assert!(!state.can_exchange());
assert_eq!(state.did(), Some("did:plc:test"));
assert!(state.code().is_none());
}
#[test]
fn test_auth_flow_state_authorized() {
let data = make_request_data(
Some("did:plc:test".into()),
Some("auth-code-123".into()),
Duration::minutes(5),
);
let state = AuthFlowState::from_request_data(&data);
assert!(!state.is_pending());
assert!(!state.is_authenticated());
assert!(state.is_authorized());
assert!(!state.is_expired());
assert!(!state.can_authenticate());
assert!(!state.can_authorize());
assert!(state.can_exchange());
assert_eq!(state.did(), Some("did:plc:test"));
assert_eq!(state.code(), Some("auth-code-123"));
}
#[test]
fn test_auth_flow_state_expired() {
let data = make_request_data(
Some("did:plc:test".into()),
Some("code".into()),
Duration::minutes(-1),
);
let state = AuthFlowState::from_request_data(&data);
assert!(state.is_expired());
assert!(!state.can_authenticate());
assert!(!state.can_authorize());
assert!(!state.can_exchange());
}
#[test]
fn test_refresh_token_state_valid() {
let state = RefreshTokenState::Valid;
assert!(state.is_valid());
assert!(state.is_usable());
assert!(!state.is_used());
assert!(!state.is_in_grace_period());
assert!(!state.is_expired());
assert!(!state.is_revoked());
}
#[test]
fn test_refresh_token_state_grace_period() {
let state = RefreshTokenState::InGracePeriod {
rotated_at: Utc::now(),
};
assert!(!state.is_valid());
assert!(state.is_usable());
assert!(!state.is_used());
assert!(state.is_in_grace_period());
}
#[test]
fn test_refresh_token_state_used() {
let state = RefreshTokenState::Used { at: Utc::now() };
assert!(!state.is_valid());
assert!(!state.is_usable());
assert!(state.is_used());
}
#[test]
fn test_refresh_token_state_expired() {
let state = RefreshTokenState::Expired;
assert!(!state.is_usable());
assert!(state.is_expired());
}
#[test]
fn test_refresh_token_state_revoked() {
let state = RefreshTokenState::Revoked;
assert!(!state.is_usable());
assert!(state.is_revoked());
}
}

Some files were not shown because too many files have changed in this diff Show More