Idk. Code quality in general?

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