Cargo clippy typeshit

This commit is contained in:
lewis
2025-12-16 21:23:11 +02:00
parent 6da77b6565
commit c24a942a28
151 changed files with 5093 additions and 3069 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ AWS_SECRET_ACCESS_KEY=minioadmin
# Appview URL for proxying app.bsky.* requests
# APPVIEW_URL=https://api.bsky.app
# Comma-separated list of relay URLs to notify via requestCrawl
# CRAWLERS=https://bsky.network
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
# =============================================================================
# Firehose (subscribeRepos WebSocket)
# =============================================================================
+25 -10
View File
@@ -1,43 +1,58 @@
# BSPDS
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, written in rust with postgres and s3-compatible blob storage.
## Features
- Full AT Protocol support (`com.atproto.*` endpoints)
- OAuth 2.1 provider (PKCE, DPoP, PAR)
- WebSocket firehose (`subscribeRepos`)
- Multi-channel notifications (email, discord, telegram, signal)
- Built-in web UI for account management
- Per-IP rate limiting
## Quick Start
```bash
cp .env.example .env
podman compose up -d
just run
```
## Configuration
See `.env.example` for all configuration options.
## Development
Run `just` to see available commands.
```bash
just test # run tests
just lint # clippy + fmt
just test
just lint
```
## Production Deployment
### Quick Deploy (Docker/Podman Compose)
Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 48`.
```bash
cp .env.prod.example .env.prod
# Edit .env.prod with your values (generate secrets with: openssl rand -base64 48)
podman-compose -f docker-compose.prod.yml up -d
```
### Full Installation Guides
### Installation Guides
| Guide | Best For |
|-------|----------|
| **Native Installation** | Maximum performance, full control |
| [Debian](docs/install-debian.md) | Debian 13+ with systemd |
| [Alpine](docs/install-alpine.md) | Alpine 3.23+ with OpenRC |
| [OpenBSD](docs/install-openbsd.md) | OpenBSD 7.8+ with rc.d |
| **Containerized** | Easier updates, isolation |
| [Containers](docs/install-containers.md) | Podman with quadlets (Debian) or OpenRC (Alpine) |
| **Orchestrated** | High availability, auto-scaling |
| [Kubernetes](docs/install-kubernetes.md) | Multi-node k8s cluster deployment |
| [Containers](docs/install-containers.md) | Podman with quadlets or OpenRC |
| [Kubernetes](docs/install-kubernetes.md) | You know what you're doing |
## License
TBD
+1 -1
View File
@@ -7,7 +7,7 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno
- s3-compatible object storage (minio operator, or just use a managed service)
- the app itself (it's just a container with some env vars)
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com` — user handles are served as subdomains.
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains.
The container image expects:
- `DATABASE_URL` - postgres connection string
+5 -4
View File
@@ -1,12 +1,12 @@
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCES_COUNT: usize = 100;
@@ -75,7 +75,8 @@ pub async fn get_preferences(
let preferences: Vec<Value> = prefs
.into_iter()
.filter(|row| {
row.name == APP_BSKY_NAMESPACE || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
row.name == APP_BSKY_NAMESPACE
|| row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
})
.filter_map(|row| {
if row.name == "app.bsky.actor.defs#declaredAgePref" {
@@ -221,7 +222,7 @@ pub async fn put_preferences(
.into_response();
}
}
if let Err(_) = tx.commit().await {
if tx.commit().await.is_err() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})),
+71 -24
View File
@@ -1,14 +1,14 @@
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use jacquard_repo::storage::BlockStore;
use crate::api::proxy_client::proxy_client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::HashMap;
use tracing::{error, info};
@@ -79,9 +79,13 @@ async fn proxy_to_appview(
let appview_url = match std::env::var("APPVIEW_URL") {
Ok(url) => url,
Err(_) => {
return Err(
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "No upstream AppView configured"}))).into_response()
);
return Err((
StatusCode::BAD_GATEWAY,
Json(
json!({"error": "UpstreamError", "message": "No upstream AppView configured"}),
),
)
.into_response());
}
};
let target_url = format!("{}/xrpc/{}", appview_url, method);
@@ -89,34 +93,53 @@ async fn proxy_to_appview(
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(params);
if let Some(key_bytes) = auth_key_bytes {
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
let appview_did =
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
Ok(service_token) => {
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
request_builder =
request_builder.header("Authorization", format!("Bearer {}", service_token));
}
Err(e) => {
error!("Failed to create service token: {:?}", e);
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response());
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response());
}
}
}
match request_builder.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
match resp.json::<Value>().await {
Ok(body) => Ok((status, body)),
Err(e) => {
error!("Error parsing proxy response: {:?}", e);
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
Err((
StatusCode::BAD_GATEWAY,
Json(json!({"error": "UpstreamError"})),
)
.into_response())
}
}
}
Err(e) => {
error!("Error sending proxy request: {:?}", e);
if e.is_timeout() {
Err((StatusCode::GATEWAY_TIMEOUT, Json(json!({"error": "UpstreamTimeout"}))).into_response())
Err((
StatusCode::GATEWAY_TIMEOUT,
Json(json!({"error": "UpstreamTimeout"})),
)
.into_response())
} else {
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
Err((
StatusCode::BAD_GATEWAY,
Json(json!({"error": "UpstreamError"})),
)
.into_response())
}
}
}
@@ -130,7 +153,9 @@ pub async fn get_profile(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_user = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
crate::auth::validate_bearer_token(&state.db, &token)
.await
.ok()
} else {
None
}
@@ -141,7 +166,14 @@ pub async fn get_profile(
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
let mut query_params = HashMap::new();
query_params.insert("actor".to_string(), params.actor.clone());
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
let (status, body) = match proxy_to_appview(
"app.bsky.actor.getProfile",
&query_params,
auth_did.as_deref().unwrap_or(""),
auth_key_bytes.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
@@ -151,16 +183,18 @@ pub async fn get_profile(
let mut profile: ProfileViewDetailed = match serde_json::from_value(body) {
Ok(p) => p,
Err(_) => {
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response();
return (
StatusCode::BAD_GATEWAY,
Json(json!({"error": "UpstreamError", "message": "Invalid profile response"})),
)
.into_response();
}
};
if let Some(ref did) = auth_did {
if profile.did == *did {
if let Some(local_record) = get_local_profile_record(&state, did).await {
if let Some(ref did) = auth_did
&& profile.did == *did
&& let Some(local_record) = get_local_profile_record(&state, did).await {
munge_profile_with_local(&mut profile, &local_record);
}
}
}
(StatusCode::OK, Json(profile)).into_response()
}
@@ -172,7 +206,9 @@ pub async fn get_profiles(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_user = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
crate::auth::validate_bearer_token(&state.db, &token)
.await
.ok()
} else {
None
}
@@ -183,7 +219,14 @@ pub async fn get_profiles(
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
let mut query_params = HashMap::new();
query_params.insert("actors".to_string(), params.actors.clone());
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
let (status, body) = match proxy_to_appview(
"app.bsky.actor.getProfiles",
&query_params,
auth_did.as_deref().unwrap_or(""),
auth_key_bytes.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
@@ -193,7 +236,11 @@ pub async fn get_profiles(
let mut output: GetProfilesOutput = match serde_json::from_value(body) {
Ok(p) => p,
Err(_) => {
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response();
return (
StatusCode::BAD_GATEWAY,
Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"})),
)
.into_response();
}
};
if let Some(ref did) = auth_did {
+31 -11
View File
@@ -121,24 +121,39 @@ pub async fn delete_account(
.execute(&mut *tx)
.await
{
error!("Failed to delete app passwords for user {}: {:?}", user_id, e);
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
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);
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
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);
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)
@@ -170,8 +185,13 @@ pub async fn delete_account(
)
.into_response();
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await {
warn!("Failed to sequence account deletion event for {}: {}", did, e);
if let Err(e) =
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
{
warn!(
"Failed to sequence account deletion event for {}: {}",
did, e
);
}
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
(StatusCode::OK, Json(json!({}))).into_response()
+1 -5
View File
@@ -104,11 +104,7 @@ pub async fn send_email(
let result = crate::notifications::enqueue_notification(&state.db, notification).await;
match result {
Ok(_) => {
tracing::info!(
"Admin email queued for {} ({})",
handle,
recipient_did
);
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
}
Err(e) => {
+14 -16
View File
@@ -65,22 +65,20 @@ pub async fn get_account_info(
.fetch_optional(&state.db)
.await;
match result {
Ok(Some(row)) => {
(
StatusCode::OK,
Json(AccountInfo {
did: row.did,
handle: row.handle,
email: 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(Some(row)) => (
StatusCode::OK,
Json(AccountInfo {
did: row.did,
handle: row.handle,
email: 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"})),
+10 -7
View File
@@ -4,14 +4,17 @@ mod info;
mod profile;
mod update;
pub use delete::{delete_account, DeleteAccountInput};
pub use email::{send_email, SendEmailInput, SendEmailOutput};
pub use delete::{DeleteAccountInput, delete_account};
pub use email::{SendEmailInput, SendEmailOutput, send_email};
pub use info::{
get_account_info, get_account_infos, AccountInfo, GetAccountInfoParams, GetAccountInfosOutput,
GetAccountInfosParams,
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams,
get_account_info, get_account_infos,
};
pub use profile::{
CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile,
create_record_admin,
};
pub use profile::{create_profile, create_record_admin, CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput};
pub use update::{
update_account_email, update_account_handle, update_account_password, UpdateAccountEmailInput,
UpdateAccountHandleInput, UpdateAccountPasswordInput,
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
update_account_email, update_account_handle, update_account_password,
};
+7 -11
View File
@@ -74,7 +74,9 @@ pub async fn create_profile(
"app.bsky.actor.profile",
"self",
&profile_record,
).await {
)
.await
{
Ok((uri, commit_cid)) => {
info!(did = %did, uri = %uri, "Created profile for user");
(
@@ -120,17 +122,11 @@ pub async fn create_record_admin(
.into_response();
}
let rkey = input.rkey.unwrap_or_else(|| {
chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string()
});
let rkey = input
.rkey
.unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string());
match create_record_internal(
&state,
did,
&input.collection,
&rkey,
&input.record,
).await {
match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await {
Ok((uri, commit_cid)) => {
info!(did = %did, uri = %uri, "Admin created record");
(
+17 -7
View File
@@ -96,7 +96,9 @@ pub async fn update_account_handle(
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
Json(
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
),
)
.into_response();
}
@@ -105,9 +107,13 @@ pub async fn update_account_handle(
.await
.ok()
.flatten();
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did)
.fetch_optional(&state.db)
.await;
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,
@@ -183,9 +189,13 @@ pub async fn update_account_password(
.into_response();
}
};
let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did)
.execute(&state.db)
.await;
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 {
+45 -17
View File
@@ -31,9 +31,12 @@ pub async fn disable_invite_codes(
}
if let Some(codes) = &input.codes {
for code in codes {
let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code)
.execute(&state.db)
.await;
let _ = sqlx::query!(
"UPDATE invite_codes SET disabled = TRUE WHERE code = $1",
code
)
.execute(&state.db)
.await;
}
}
if let Some(accounts) = &input.accounts {
@@ -106,7 +109,16 @@ pub async fn get_invite_codes(
_ => "created_at DESC",
};
let codes_result = if let Some(cursor) = &params.cursor {
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
sqlx::query_as::<
_,
(
String,
i32,
Option<bool>,
uuid::Uuid,
chrono::DateTime<chrono::Utc>,
),
>(&format!(
r#"
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
@@ -121,7 +133,16 @@ pub async fn get_invite_codes(
.fetch_all(&state.db)
.await
} else {
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
sqlx::query_as::<
_,
(
String,
i32,
Option<bool>,
uuid::Uuid,
chrono::DateTime<chrono::Utc>,
),
>(&format!(
r#"
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
FROM invite_codes ic
@@ -147,12 +168,13 @@ pub async fn get_invite_codes(
};
let mut codes = Vec::new();
for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows {
let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string());
let creator_did =
sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.unwrap_or_else(|| "unknown".to_string());
let uses_result = sqlx::query!(
r#"
SELECT u.did, icu.used_at
@@ -226,9 +248,12 @@ pub async fn disable_account_invites(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account)
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET invites_disabled = TRUE WHERE did = $1",
account
)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
@@ -277,9 +302,12 @@ pub async fn enable_account_invites(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account)
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET invites_disabled = FALSE WHERE did = $1",
account
)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() == 0 {
+47 -18
View File
@@ -142,9 +142,12 @@ pub async fn get_subject_status(
}
}
if let Some(blob_cid) = &params.blob {
let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid)
.fetch_optional(&state.db)
.await;
let blob = sqlx::query!(
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
blob_cid
)
.fetch_optional(&state.db)
.await;
match blob {
Ok(Some(row)) => {
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
@@ -263,15 +266,15 @@ pub async fn update_subject_status(
.execute(&mut *tx)
.await
} else {
sqlx::query!(
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
did
)
.execute(&mut *tx)
.await
sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
.execute(&mut *tx)
.await
};
if let Err(e) = result {
error!("Failed to update user deactivation status for {}: {:?}", did, e);
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"})),
@@ -288,20 +291,43 @@ pub async fn update_subject_status(
.into_response();
}
if let Some(takedown) = &input.takedown {
let status = if takedown.apply { Some("takendown") } else { None };
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !takedown.apply, status).await {
let status = if takedown.apply {
Some("takendown")
} else {
None
};
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
did,
!takedown.apply,
status,
)
.await
{
warn!("Failed to sequence account event for takedown: {}", e);
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.apply { Some("deactivated") } else { None };
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !deactivated.apply, status).await {
let status = if deactivated.apply {
Some("deactivated")
} else {
None
};
if let Err(e) = crate::api::repo::record::sequence_account_event(
&state,
did,
!deactivated.apply,
status,
)
.await
{
warn!("Failed to sequence account event for deactivation: {}", e);
}
}
if let Ok(Some(handle)) = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
if let Ok(Some(handle)) =
sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
{
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
}
@@ -338,7 +364,10 @@ pub async fn update_subject_status(
.execute(&state.db)
.await
{
error!("Failed to update record takedown status for {}: {:?}", uri, e);
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"})),
+24 -9
View File
@@ -46,7 +46,11 @@ pub enum ApiError {
UpstreamFailure,
UpstreamTimeout,
UpstreamUnavailable(String),
UpstreamError { status: u16, error: Option<String>, message: Option<String> },
UpstreamError {
status: u16,
error: Option<String>,
message: Option<String>,
},
}
impl ApiError {
@@ -135,16 +139,27 @@ impl ApiError {
_ => None,
}
}
pub fn from_upstream_response(
status: u16,
body: &[u8],
) -> Self {
pub fn from_upstream_response(status: u16, body: &[u8]) -> Self {
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) {
let error = parsed.get("error").and_then(|v| v.as_str()).map(String::from);
let message = parsed.get("message").and_then(|v| v.as_str()).map(String::from);
return Self::UpstreamError { status, error, message };
let error = parsed
.get("error")
.and_then(|v| v.as_str())
.map(String::from);
let message = parsed
.get("message")
.and_then(|v| v.as_str())
.map(String::from);
return Self::UpstreamError {
status,
error,
message,
};
}
Self::UpstreamError {
status,
error: None,
message: None,
}
Self::UpstreamError { status, error: None, message: None }
}
}
+17 -9
View File
@@ -1,13 +1,13 @@
use crate::api::read_after_write::{
extract_repo_rev, format_munged_response, get_local_lag, get_records_since_rev,
proxy_to_appview, FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript,
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
};
use crate::state::AppState;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::Value;
@@ -68,7 +68,9 @@ pub async fn get_actor_likes(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_user = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
crate::auth::validate_bearer_token(&state.db, &token)
.await
.ok()
} else {
None
}
@@ -85,11 +87,17 @@ pub async fn get_actor_likes(
if let Some(cursor) = &params.cursor {
query_params.insert("cursor".to_string(), cursor.clone());
}
let proxy_result =
match proxy_to_appview("app.bsky.feed.getActorLikes", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
Ok(r) => r,
Err(e) => return e,
};
let proxy_result = match proxy_to_appview(
"app.bsky.feed.getActorLikes",
&query_params,
auth_did.as_deref().unwrap_or(""),
auth_key_bytes.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return proxy_result.into_response();
}
+21 -21
View File
@@ -1,14 +1,14 @@
use crate::api::read_after_write::{
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost,
ProfileRecord, RecordDescript,
FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post,
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
proxy_to_appview,
};
use crate::state::AppState;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use std::collections::HashMap;
@@ -30,11 +30,10 @@ fn update_author_profile_in_feed(
local_profile: &RecordDescript<ProfileRecord>,
) {
for item in feed.iter_mut() {
if item.post.author.did == author_did {
if let Some(ref display_name) = local_profile.record.display_name {
if item.post.author.did == author_did
&& let Some(ref display_name) = local_profile.record.display_name {
item.post.author.display_name = Some(display_name.clone());
}
}
}
}
@@ -46,7 +45,9 @@ pub async fn get_author_feed(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_user = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
crate::auth::validate_bearer_token(&state.db, &token)
.await
.ok()
} else {
None
}
@@ -69,11 +70,17 @@ pub async fn get_author_feed(
if let Some(include_pins) = params.include_pins {
query_params.insert("includePins".to_string(), include_pins.to_string());
}
let proxy_result =
match proxy_to_appview("app.bsky.feed.getAuthorFeed", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
Ok(r) => r,
Err(e) => return e,
};
let proxy_result = match proxy_to_appview(
"app.bsky.feed.getAuthorFeed",
&query_params,
auth_did.as_deref().unwrap_or(""),
auth_key_bytes.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return proxy_result.into_response();
}
@@ -144,14 +151,7 @@ pub async fn get_author_feed(
let local_posts: Vec<_> = local_records
.posts
.iter()
.map(|p| {
format_local_post(
p,
&requester_did,
&handle,
local_records.profile.as_ref(),
)
})
.map(|p| format_local_post(p, &requester_did, &handle, local_records.profile.as_ref()))
.collect();
insert_posts_into_feed(&mut feed_output.feed, local_posts);
let lag = get_local_lag(&local_records);
+13 -6
View File
@@ -1,7 +1,7 @@
use crate::api::proxy_client::{
is_ssrf_safe, proxy_client, validate_at_uri, validate_limit, MAX_RESPONSE_SIZE,
};
use crate::api::ApiError;
use crate::api::proxy_client::{
MAX_RESPONSE_SIZE, is_ssrf_safe, proxy_client, validate_at_uri, validate_limit,
};
use crate::state::AppState;
use axum::{
extract::{Query, State},
@@ -61,10 +61,17 @@ pub async fn get_feed(
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(&query_params);
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
match crate::auth::create_service_token(&auth_user.did, &appview_did, "app.bsky.feed.getFeed", key_bytes) {
let appview_did =
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
match crate::auth::create_service_token(
&auth_user.did,
&appview_did,
"app.bsky.feed.getFeed",
key_bytes,
) {
Ok(service_token) => {
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
request_builder =
request_builder.header("Authorization", format!("Bearer {}", service_token));
}
Err(e) => {
error!(error = ?e, "Failed to create service token for getFeed");
+41 -21
View File
@@ -1,16 +1,16 @@
use crate::api::read_after_write::{
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
get_records_since_rev, proxy_to_appview, PostRecord, PostView, RecordDescript,
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
};
use crate::state::AppState;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::HashMap;
use tracing::warn;
@@ -39,7 +39,7 @@ pub struct ThreadViewPost {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ThreadNode {
Post(ThreadViewPost),
Post(Box<ThreadViewPost>),
NotFound(ThreadNotFound),
Blocked(ThreadBlocked),
}
@@ -96,13 +96,13 @@ fn add_replies_to_thread(
})
.map(|p| {
let post_view = format_local_post(p, author_did, author_handle, None);
ThreadNode::Post(ThreadViewPost {
ThreadNode::Post(Box::new(ThreadViewPost {
thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()),
post: post_view,
parent: None,
replies: None,
extra: HashMap::new(),
})
}))
})
.collect();
if !replies.is_empty() {
@@ -114,7 +114,13 @@ fn add_replies_to_thread(
if let Some(ref mut existing_replies) = thread.replies {
for reply in existing_replies.iter_mut() {
if let ThreadNode::Post(reply_thread) = reply {
add_replies_to_thread(reply_thread, local_posts, author_did, author_handle, depth + 1);
add_replies_to_thread(
reply_thread,
local_posts,
author_did,
author_handle,
depth + 1,
);
}
}
}
@@ -128,7 +134,9 @@ pub async fn get_post_thread(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let auth_user = if let Some(h) = auth_header {
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
crate::auth::validate_bearer_token(&state.db, &token)
.await
.ok()
} else {
None
}
@@ -145,11 +153,17 @@ pub async fn get_post_thread(
if let Some(parent_height) = params.parent_height {
query_params.insert("parentHeight".to_string(), parent_height.to_string());
}
let proxy_result =
match proxy_to_appview("app.bsky.feed.getPostThread", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
Ok(r) => r,
Err(e) => return e,
};
let proxy_result = match proxy_to_appview(
"app.bsky.feed.getPostThread",
&query_params,
auth_did.as_deref().unwrap_or(""),
auth_key_bytes.as_deref(),
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
if proxy_result.status == StatusCode::NOT_FOUND {
return handle_not_found(&state, &params.uri, auth_did, &proxy_result.headers).await;
}
@@ -193,7 +207,13 @@ pub async fn get_post_thread(
}
};
if let ThreadNode::Post(ref mut thread_post) = thread_output.thread {
add_replies_to_thread(thread_post, &local_records.posts, &requester_did, &handle, 0);
add_replies_to_thread(
thread_post,
&local_records.posts,
&requester_did,
&handle,
0,
);
}
let lag = get_local_lag(&local_records);
format_munged_response(thread_output, lag)
@@ -212,7 +232,7 @@ async fn handle_not_found(
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Post not found"})),
)
.into_response()
.into_response();
}
};
let requester_did = match auth_did {
@@ -222,7 +242,7 @@ async fn handle_not_found(
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Post not found"})),
)
.into_response()
.into_response();
}
};
let uri_parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect();
@@ -248,7 +268,7 @@ async fn handle_not_found(
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Post not found"})),
)
.into_response()
.into_response();
}
};
let local_post = local_records.posts.iter().find(|p| p.uri == uri);
@@ -259,7 +279,7 @@ async fn handle_not_found(
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Post not found"})),
)
.into_response()
.into_response();
}
};
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did)
@@ -280,13 +300,13 @@ async fn handle_not_found(
local_records.profile.as_ref(),
);
let thread = PostThreadOutput {
thread: ThreadNode::Post(ThreadViewPost {
thread: ThreadNode::Post(Box::new(ThreadViewPost {
thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()),
post: post_view,
parent: None,
replies: None,
extra: HashMap::new(),
}),
})),
threadgate: None,
};
let lag = get_local_lag(&local_records);
+45 -35
View File
@@ -1,18 +1,18 @@
use crate::api::read_after_write::{
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost,
PostView,
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
proxy_to_appview,
};
use crate::state::AppState;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use jacquard_repo::storage::BlockStore;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::collections::HashMap;
use tracing::warn;
@@ -52,7 +52,13 @@ pub async fn get_timeline(
};
match std::env::var("APPVIEW_URL") {
Ok(url) if !url.starts_with("http://127.0.0.1") => {
return get_timeline_with_appview(&state, &params, &auth_user.did, auth_user.key_bytes.as_deref()).await;
return get_timeline_with_appview(
&state,
&params,
&auth_user.did,
auth_user.key_bytes.as_deref(),
)
.await;
}
_ => {}
}
@@ -75,11 +81,17 @@ async fn get_timeline_with_appview(
if let Some(cursor) = &params.cursor {
query_params.insert("cursor".to_string(), cursor.clone());
}
let proxy_result =
match proxy_to_appview("app.bsky.feed.getTimeline", &query_params, auth_did, auth_key_bytes).await {
Ok(r) => r,
Err(e) => return e,
};
let proxy_result = match proxy_to_appview(
"app.bsky.feed.getTimeline",
&query_params,
auth_did,
auth_key_bytes,
)
.await
{
Ok(r) => r,
Err(e) => return e,
};
if !proxy_result.status.is_success() {
return proxy_result.into_response();
}
@@ -127,30 +139,28 @@ async fn get_timeline_with_appview(
}
async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
let user_id: uuid::Uuid = match sqlx::query_scalar!(
"SELECT id FROM users WHERE did = $1",
auth_did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
Err(e) => {
warn!("Database error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
}
};
let user_id: uuid::Uuid =
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_did)
.fetch_optional(&state.db)
.await
{
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "User not found"})),
)
.into_response();
}
Err(e) => {
warn!("Database error fetching user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Database error"})),
)
.into_response();
}
};
let follows_query = sqlx::query!(
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.graph.follow' LIMIT 5000",
user_id
+91 -48
View File
@@ -1,5 +1,5 @@
use super::did::verify_did_web;
use crate::plc::{create_genesis_operation, signing_key_to_did_key, PlcClient};
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
@@ -10,7 +10,7 @@ use axum::{
use bcrypt::{DEFAULT_COST, hash};
use jacquard::types::{did::Did, integer::LimitedU32, string::Tid};
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use k256::{ecdsa::SigningKey, SecretKey};
use k256::{SecretKey, ecdsa::SigningKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -18,18 +18,15 @@ use std::sync::Arc;
use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"unknown".to_string()
}
@@ -64,7 +61,10 @@ pub async fn create_account(
) -> Response {
info!("create_account called");
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::AccountCreation, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
.await
{
warn!(ip = %client_ip, "Account creation rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -84,18 +84,19 @@ pub async fn create_account(
)
.into_response();
}
let email: Option<String> = input.email.as_ref()
let email: Option<String> = input
.email
.as_ref()
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty());
if let Some(ref email) = email {
if !crate::api::validation::is_valid_email(email) {
if let Some(ref email) = email
&& !crate::api::validation::is_valid_email(email) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
)
.into_response();
}
}
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
let valid_channels = ["email", "discord", "telegram", "signal"];
if !valid_channels.contains(&verification_channel) {
@@ -220,7 +221,10 @@ pub async fn create_account(
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
@@ -269,7 +273,10 @@ pub async fn create_account(
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
@@ -316,10 +323,12 @@ pub async fn create_account(
Ok(None) => {}
}
if let Some(code) = &input.invite_code {
let invite_query =
sqlx::query!("SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", code)
.fetch_optional(&mut *tx)
.await;
let invite_query = sqlx::query!(
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
code
)
.fetch_optional(&mut *tx)
.await;
match invite_query {
Ok(Some(row)) => {
if row.available_uses <= 0 {
@@ -378,23 +387,41 @@ pub async fn create_account(
discord_id, telegram_username, signal_number
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
)
.bind(short_handle)
.bind(&email)
.bind(&did)
.bind(&password_hash)
.bind(&verification_code)
.bind(&code_expires_at)
.bind(verification_channel)
.bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
.bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
.bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
.fetch_one(&mut *tx)
.await;
.bind(short_handle)
.bind(&email)
.bind(&did)
.bind(&password_hash)
.bind(&verification_code)
.bind(code_expires_at)
.bind(verification_channel)
.bind(
input
.discord_id
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty()),
)
.bind(
input
.telegram_username
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty()),
)
.bind(
input
.signal_number
.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty()),
)
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code().as_deref() == Some("23505") {
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("23505") {
let constraint = db_err.constraint().unwrap_or("");
if constraint.contains("handle") || constraint.contains("users_handle") {
return (
@@ -425,7 +452,6 @@ pub async fn create_account(
.into_response();
}
}
}
error!("Error inserting user: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -535,9 +561,13 @@ pub async fn create_account(
}
};
let commit_cid_str = commit_cid.to_string();
let repo_insert = sqlx::query!("INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", user_id, commit_cid_str)
.execute(&mut *tx)
.await;
let repo_insert = sqlx::query!(
"INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)",
user_id,
commit_cid_str
)
.execute(&mut *tx)
.await;
if let Err(e) = repo_insert {
error!("Error initializing repo: {:?}", e);
return (
@@ -547,10 +577,13 @@ pub async fn create_account(
.into_response();
}
if let Some(code) = &input.invite_code {
let use_insert =
sqlx::query!("INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", code, user_id)
.execute(&mut *tx)
.await;
let use_insert = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
if let Err(e) = use_insert {
error!("Error recording invite usage: {:?}", e);
return (
@@ -568,10 +601,13 @@ pub async fn create_account(
)
.into_response();
}
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await {
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
@@ -584,7 +620,9 @@ pub async fn create_account(
"app.bsky.actor.profile",
"self",
&profile_record,
).await {
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
if let Err(e) = crate::notifications::enqueue_signup_verification(
@@ -593,8 +631,13 @@ pub async fn create_account(
verification_channel,
&verification_recipient,
&verification_code,
).await {
warn!("Failed to enqueue signup verification notification: {:?}", e);
)
.await
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
}
(
StatusCode::OK,
+57 -34
View File
@@ -47,7 +47,10 @@ pub async fn resolve_handle(
.await;
match user {
Ok(Some(row)) => {
let _ = state.cache.set(&cache_key, &row.did, std::time::Duration::from_secs(300)).await;
let _ = state
.cache
.set(&cache_key, &row.did, std::time::Duration::from_secs(300))
.await;
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
}
Ok(None) => (
@@ -127,22 +130,23 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
)
.into_response();
}
let key_row = sqlx::query!("SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await;
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await;
let key_bytes: Vec<u8> = match key_row {
Ok(Some(row)) => {
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
}
},
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -283,7 +287,7 @@ pub async fn get_recommended_did_credentials(
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())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -298,16 +302,24 @@ pub async fn get_recommended_did_credentials(
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", auth_user.did)
.fetch_optional(&state.db)
.await
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 ApiError::InternalError.into_response(),
};
let key_bytes = match auth_user.key_bytes {
Some(kb) => kb,
None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot get DID credentials".into()).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());
let pds_endpoint = format!("https://{}", hostname);
@@ -352,7 +364,7 @@ pub async fn update_handle(
Json(input): Json<UpdateHandleInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
@@ -378,7 +390,9 @@ pub async fn update_handle(
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
Json(
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
),
)
.into_response();
}
@@ -387,9 +401,13 @@ pub async fn update_handle(
.await
.ok()
.flatten();
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND id != $2", new_handle, user_id)
.fetch_optional(&state.db)
.await;
let existing = sqlx::query!(
"SELECT id FROM users WHERE handle = $1 AND id != $2",
new_handle,
user_id
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(_)) = existing {
return (
StatusCode::BAD_REQUEST,
@@ -397,18 +415,26 @@ pub async fn update_handle(
)
.into_response();
}
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE id = $2", new_handle, user_id)
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET handle = $1 WHERE id = $2",
new_handle,
user_id
)
.execute(&state.db)
.await;
match result {
Ok(_) => {
if let Some(old) = old_handle {
let _ = state.cache.delete(&format!("handle:{}", old)).await;
}
let _ = state.cache.delete(&format!("handle:{}", new_handle)).await;
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let full_handle = format!("{}.{}", new_handle, hostname);
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
if let Err(e) =
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
.await
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
(StatusCode::OK, Json(json!({}))).into_response()
@@ -424,10 +450,7 @@ pub async fn update_handle(
}
}
pub async fn well_known_atproto_did(
State(state): State<AppState>,
headers: HeaderMap,
) -> Response {
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let host = match headers.get("host").and_then(|h| h.to_str().ok()) {
Some(h) => h,
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
+2 -2
View File
@@ -4,7 +4,7 @@ pub mod plc;
pub use account::create_account;
pub use did::{
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did,
well_known_atproto_did,
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc,
well_known_atproto_did, well_known_did,
};
pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation};
+2 -2
View File
@@ -3,5 +3,5 @@ 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};
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
+7 -9
View File
@@ -1,10 +1,10 @@
use crate::api::ApiError;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use chrono::{Duration, Utc};
use serde_json::json;
@@ -67,16 +67,14 @@ pub async fn request_plc_operation_signature(
.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
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);
info!(
"PLC operation signature requested for user {}",
auth_user.did
);
(StatusCode::OK, Json(json!({}))).into_response()
}
+24 -17
View File
@@ -1,19 +1,19 @@
use crate::api::ApiError;
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
use crate::plc::{
create_update_op, sign_operation, PlcClient, PlcError, PlcOpOrTombstone, PlcService,
PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation,
};
use crate::state::AppState;
use axum::{
Json,
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 serde_json::{Value, json};
use std::collections::HashMap;
use tracing::{error, info, warn};
@@ -59,8 +59,9 @@ pub async fn sign_plc_operation(
Some(t) => t,
None => {
return ApiError::InvalidRequest(
"Email confirmation token required to sign PLC operations".into()
).into_response();
"Email confirmation token required to sign PLC operations".into(),
)
.into_response();
}
};
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did)
@@ -105,9 +106,12 @@ pub async fn sign_plc_operation(
}
};
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;
let _ = sqlx::query!(
"DELETE FROM plc_operation_tokens WHERE id = $1",
token_row.id
)
.execute(&state.db)
.await;
return (
StatusCode::BAD_REQUEST,
Json(json!({
@@ -158,11 +162,11 @@ pub async fn sign_plc_operation(
};
let plc_client = PlcClient::new(None);
let did_clone = did.clone();
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> = with_circuit_breaker(
&state.circuit_breakers.plc_directory,
|| async { plc_client.get_last_op(&did_clone).await },
)
.await;
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> =
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client.get_last_op(&did_clone).await
})
.await;
let last_op = match result {
Ok(op) => op,
Err(CircuitBreakerError::CircuitOpen(e)) => {
@@ -259,9 +263,12 @@ pub async fn sign_plc_operation(
.into_response();
}
};
let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id)
.execute(&state.db)
.await;
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,
+16 -17
View File
@@ -1,16 +1,16 @@
use crate::api::ApiError;
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient, PlcError};
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use k256::ecdsa::SigningKey;
use serde::Deserialize;
use serde_json::{json, Value};
use serde_json::{Value, json};
use tracing::{error, info, warn};
#[derive(Debug, Deserialize)]
@@ -110,8 +110,8 @@ pub async fn submit_plc_operation(
.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()) {
if let Some(services) = op.get("services").and_then(|v| v.as_object())
&& 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") {
@@ -135,10 +135,9 @@ pub async fn submit_plc_operation(
.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 {
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
&& atproto_key != user_did_key {
return (
StatusCode::BAD_REQUEST,
Json(json!({
@@ -148,8 +147,6 @@ pub async fn submit_plc_operation(
)
.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());
@@ -167,11 +164,13 @@ pub async fn submit_plc_operation(
let plc_client = PlcClient::new(None);
let operation_clone = input.operation.clone();
let did_clone = did.clone();
let result: Result<(), CircuitBreakerError<PlcError>> = with_circuit_breaker(
&state.circuit_breakers.plc_directory,
|| async { plc_client.send_operation(&did_clone, &operation_clone).await },
)
.await;
let result: Result<(), CircuitBreakerError<PlcError>> =
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
plc_client
.send_operation(&did_clone, &operation_clone)
.await
})
.await;
match result {
Ok(()) => {}
Err(CircuitBreakerError::CircuitOpen(e)) => {
+1 -1
View File
@@ -15,4 +15,4 @@ pub mod temp;
pub mod validation;
pub use error::ApiError;
pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts};
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
+1 -1
View File
@@ -35,7 +35,7 @@ pub async fn create_report(
Json(input): Json<CreateReportInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
+2 -2
View File
@@ -1,11 +1,11 @@
use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did};
use crate::api::ApiError;
use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
+36 -38
View File
@@ -1,3 +1,5 @@
use crate::auth::validate_bearer_token;
use crate::state::AppState;
use axum::{
Json,
extract::State,
@@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::Row;
use tracing::info;
use crate::auth::validate_bearer_token;
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -24,21 +24,16 @@ pub struct NotificationPrefsResponse {
pub signal_verified: bool,
}
pub async fn get_notification_prefs(
State(state): State<AppState>,
headers: HeaderMap,
) -> Response {
pub async fn get_notification_prefs(State(state): State<AppState>, headers: 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", "message": "Authentication required"})),
)
.into_response()
}
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
@@ -47,11 +42,12 @@ pub async fn get_notification_prefs(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response()
.into_response();
}
};
let row = match sqlx::query(
r#"
let row =
match sqlx::query(
r#"
SELECT
email,
preferred_notification_channel::text as channel,
@@ -63,21 +59,21 @@ pub async fn get_notification_prefs(
signal_verified
FROM users
WHERE did = $1
"#
)
.bind(&user.did)
.fetch_one(&state.db)
.await
{
Ok(r) => r,
Err(e) => {
return (
"#,
)
.bind(&user.did)
.fetch_one(&state.db)
.await
{
Ok(r) => r,
Err(e) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
Json(
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
),
)
.into_response()
}
};
.into_response(),
};
let email: String = row.get("email");
let channel: String = row.get("channel");
let discord_id: Option<String> = row.get("discord_id");
@@ -117,13 +113,11 @@ pub async fn update_notification_prefs(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response()
}
None => return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
)
.into_response(),
};
let user = match validate_bearer_token(&state.db, &token).await {
Ok(u) => u,
@@ -132,7 +126,7 @@ pub async fn update_notification_prefs(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
)
.into_response()
.into_response();
}
};
if let Some(ref channel) = input.preferred_channel {
@@ -208,7 +202,11 @@ pub async fn update_notification_prefs(
info!(did = %user.did, "Updated Telegram username");
}
if let Some(ref signal) = input.signal_number {
let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
let signal_clean: Option<&str> = if signal.is_empty() {
None
} else {
Some(signal.as_str())
};
if let Err(e) = sqlx::query(
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
)
+15 -22
View File
@@ -1,3 +1,4 @@
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use axum::{
body::Bytes,
@@ -5,19 +6,15 @@ use axum::{
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
};
use crate::api::proxy_client::proxy_client;
use std::collections::HashMap;
use tracing::error;
fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> {
if did_with_fragment.starts_with("did:web:") {
let without_prefix = &did_with_fragment[8..];
if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") {
let host = without_prefix.split('#').next()?;
let url = format!("https://{}", host);
let did_without_fragment = format!("did:web:{}", host);
Some((url, did_without_fragment))
} else if did_with_fragment.starts_with("did:plc:") {
None
} else {
None
}
@@ -41,7 +38,8 @@ pub async fn proxy_handler(
Some(resolved) => resolved,
None => {
error!(did = %did_str, "Could not resolve service DID");
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID").into_response();
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
.into_response();
}
};
(url, Some(did_without_fragment))
@@ -50,7 +48,8 @@ pub async fn proxy_handler(
let url = match std::env::var("APPVIEW_URL") {
Ok(url) => url,
Err(_) => {
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response();
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured")
.into_response();
}
};
let aud = std::env::var("APPVIEW_DID").ok();
@@ -60,26 +59,20 @@ pub async fn proxy_handler(
let target_url = format!("{}/xrpc/{}", appview_url, method);
let client = proxy_client();
let mut request_builder = client.request(method_verb, &target_url).query(&params);
let mut auth_header_val = headers.get("Authorization").map(|h| h.clone());
if let Some(aud) = &service_aud {
if let Some(token) = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
if let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await {
if let Some(key_bytes) = auth_user.key_bytes {
if let Ok(new_token) =
let mut auth_header_val = headers.get("Authorization").cloned();
if let Some(aud) = &service_aud
&& let Some(token) = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
&& let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await
&& let Some(key_bytes) = auth_user.key_bytes
&& let Ok(new_token) =
crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes)
{
if let Ok(val) =
&& let Ok(val) =
axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
{
auth_header_val = Some(val);
}
}
}
}
}
}
if let Some(val) = auth_header_val {
request_builder = request_builder.header("Authorization", val);
}
+14 -5
View File
@@ -20,7 +20,9 @@ pub fn proxy_client() -> &'static Client {
.pool_idle_timeout(Duration::from_secs(90))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to build HTTP client - this indicates a TLS or system configuration issue")
.expect(
"Failed to build HTTP client - this indicates a TLS or system configuration issue",
)
})
}
@@ -48,7 +50,9 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
}
return Ok(());
}
let port = parsed.port().unwrap_or(if scheme == "https" { 443 } else { 80 });
let port = parsed
.port()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let socket_addrs: Vec<SocketAddr> = match (host, port).to_socket_addrs() {
Ok(addrs) => addrs.collect(),
Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())),
@@ -104,7 +108,9 @@ impl std::fmt::Display for SsrfError {
SsrfError::InsecureProtocol(p) => write!(f, "Insecure protocol: {}", p),
SsrfError::NoHost => write!(f, "No host in URL"),
SsrfError::NonUnicastIp(ip) => write!(f, "Non-unicast IP address: {}", ip),
SsrfError::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for: {}", host),
SsrfError::DnsResolutionFailed(host) => {
write!(f, "DNS resolution failed for: {}", host)
}
}
}
}
@@ -158,7 +164,7 @@ pub struct AtUriParts {
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
match limit {
Some(l) if l == 0 => default,
Some(0) => default,
Some(l) if l > max => max,
Some(l) => l,
None => default,
@@ -190,7 +196,10 @@ mod tests {
#[test]
fn test_ssrf_blocks_http_by_default() {
let result = is_ssrf_safe("http://external.example.com/xrpc/test");
assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))));
assert!(matches!(
result,
Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))
));
}
#[test]
fn test_ssrf_allows_localhost_http() {
+20 -19
View File
@@ -1,12 +1,12 @@
use crate::api::proxy_client::{
is_ssrf_safe, proxy_client, MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD,
};
use crate::api::ApiError;
use crate::api::proxy_client::{
MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD, is_ssrf_safe, proxy_client,
};
use crate::state::AppState;
use axum::{
Json,
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use bytes::Bytes;
use chrono::{DateTime, Utc};
@@ -182,8 +182,8 @@ pub async fn get_records_since_rev(
record,
});
}
} else if data.collection == "app.bsky.feed.like" {
if let Ok(record) = serde_ipld_dagcbor::from_slice::<LikeRecord>(&block_bytes) {
} else if data.collection == "app.bsky.feed.like"
&& let Ok(record) = serde_ipld_dagcbor::from_slice::<LikeRecord>(&block_bytes) {
result.likes.push(RecordDescript {
uri,
cid: data.cid_str,
@@ -191,7 +191,6 @@ pub async fn get_records_since_rev(
record,
});
}
}
}
Ok(result)
}
@@ -250,18 +249,21 @@ pub async fn proxy_to_appview(
})?;
if let Err(e) = is_ssrf_safe(&appview_url) {
error!("SSRF check failed for appview URL: {}", e);
return Err(ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
.into_response());
return Err(
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
);
}
let target_url = format!("{}/xrpc/{}", appview_url, method);
info!(target = %target_url, "Proxying request to appview");
let client = proxy_client();
let mut request_builder = client.get(&target_url).query(params);
if let Some(key_bytes) = auth_key_bytes {
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
let appview_did =
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
Ok(service_token) => {
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
request_builder =
request_builder.header("Authorization", format!("Bearer {}", service_token));
}
Err(e) => {
error!(error = ?e, "Failed to create service token");
@@ -287,9 +289,7 @@ pub async fn proxy_to_appview(
Some((name, value))
})
.collect();
let content_length = resp
.content_length()
.unwrap_or(0);
let content_length = resp.content_length().unwrap_or(0);
if content_length > MAX_RESPONSE_SIZE {
error!(
content_length,
@@ -321,8 +321,10 @@ pub async fn proxy_to_appview(
if e.is_timeout() {
Err(ApiError::UpstreamTimeout.into_response())
} else if e.is_connect() {
Err(ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string())
.into_response())
Err(
ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string())
.into_response(),
)
} else {
Err(ApiError::UpstreamFailure.into_response())
}
@@ -332,13 +334,12 @@ pub async fn proxy_to_appview(
pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Response {
let mut response = (StatusCode::OK, Json(data)).into_response();
if let Some(lag_ms) = lag {
if let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) {
if let Some(lag_ms) = lag
&& let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) {
response
.headers_mut()
.insert(UPSTREAM_LAG_HEADER, header_val);
}
}
response
}
+27 -22
View File
@@ -30,7 +30,7 @@ pub async fn upload_blob(
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -122,8 +122,12 @@ pub async fn upload_blob(
.into_response();
}
};
if was_inserted {
if let Err(e) = state.blob_store.put_bytes(&storage_key, bytes::Bytes::from(data)).await {
if was_inserted
&& let Err(e) = state
.blob_store
.put_bytes(&storage_key, bytes::Bytes::from(data))
.await
{
error!("Failed to upload blob to storage: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -131,14 +135,15 @@ pub async fn upload_blob(
)
.into_response();
}
}
if let Err(e) = tx.commit().await {
error!("Failed to commit blob transaction: {:?}", e);
if was_inserted {
if let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
error!("Failed to cleanup orphaned blob {}: {:?}", storage_key, cleanup_err);
if was_inserted
&& let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
error!(
"Failed to cleanup orphaned blob {}: {:?}",
storage_key, cleanup_err
);
}
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -179,17 +184,13 @@ pub struct ListMissingBlobsOutput {
fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
if let Some(obj) = val.as_object() {
if let Some(type_val) = obj.get("$type") {
if type_val == "blob" {
if let Some(r) = obj.get("ref") {
if let Some(link) = r.get("$link") {
if let Some(s) = link.as_str() {
if let Some(type_val) = obj.get("$type")
&& type_val == "blob"
&& let Some(r) = obj.get("ref")
&& let Some(link) = r.get("$link")
&& let Some(s) = link.as_str() {
blobs.push(s.to_string());
}
}
}
}
}
for (_, v) in obj {
find_blobs(v, blobs);
}
@@ -206,7 +207,7 @@ pub async fn list_missing_blobs(
Query(params): Query<ListMissingBlobsParams>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -276,7 +277,7 @@ pub async fn list_missing_blobs(
let rkey = &row.rkey;
let record_cid_str = &row.record_cid;
last_cursor = Some(format!("{}|{}", collection, rkey));
let record_cid = match Cid::from_str(&record_cid_str) {
let record_cid = match Cid::from_str(record_cid_str) {
Ok(c) => c,
Err(_) => continue,
};
@@ -291,9 +292,13 @@ pub async fn list_missing_blobs(
let mut blobs = Vec::new();
find_blobs(&record_val, &mut blobs);
for blob_cid_str in blobs {
let exists = sqlx::query!("SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", blob_cid_str, user_id)
.fetch_optional(&state.db)
.await;
let exists = sqlx::query!(
"SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2",
blob_cid_str,
user_id
)
.fetch_optional(&state.db)
.await;
match exists {
Ok(None) => {
missing_blobs.push(RecordBlob {
+2 -2
View File
@@ -1,13 +1,13 @@
use crate::api::ApiError;
use crate::state::AppState;
use crate::sync::import::{apply_import, parse_car, ImportError};
use crate::sync::import::{ImportError, apply_import, parse_car};
use crate::sync::verify::CarVerifier;
use axum::{
Json,
body::Bytes,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use tracing::{debug, error, info, warn};
+20 -12
View File
@@ -18,15 +18,21 @@ pub async fn describe_repo(
Query(input): Query<DescribeRepoInput>,
) -> Response {
let user_row = if input.repo.starts_with("did:") {
sqlx::query!("SELECT id, handle, did FROM users WHERE did = $1", input.repo)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
sqlx::query!(
"SELECT id, handle, did FROM users WHERE did = $1",
input.repo
)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
} else {
sqlx::query!("SELECT id, handle, did FROM users WHERE handle = $1", input.repo)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
sqlx::query!(
"SELECT id, handle, did FROM users WHERE handle = $1",
input.repo
)
.fetch_optional(&state.db)
.await
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
};
let (user_id, handle, did) = match user_row {
Ok(Some((id, handle, did))) => (id, handle, did),
@@ -38,10 +44,12 @@ pub async fn describe_repo(
.into_response();
}
};
let collections_query =
sqlx::query!("SELECT DISTINCT collection FROM records WHERE repo_id = $1", user_id)
.fetch_all(&state.db)
.await;
let collections_query = sqlx::query!(
"SELECT DISTINCT collection FROM records WHERE repo_id = $1",
user_id
)
.fetch_all(&state.db)
.await;
let collections: Vec<String> = match collections_query {
Ok(rows) => rows.iter().map(|r| r.collection.clone()).collect(),
Err(_) => Vec::new(),
+3 -1
View File
@@ -6,4 +6,6 @@ pub mod record;
pub use blob::{list_missing_blobs, upload_blob};
pub use import::import_repo;
pub use meta::describe_repo;
pub use record::{apply_writes, create_record, delete_record, get_record, list_records, put_record};
pub use record::{
apply_writes, create_record, delete_record, get_record, list_records, put_record,
};
+79 -45
View File
@@ -1,16 +1,19 @@
use super::validation::validate_record;
use super::write::has_verified_notification_channel;
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use cid::Cid;
use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}};
use jacquard::types::{
integer::LimitedU32,
string::{Nsid, Tid},
};
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -77,7 +80,7 @@ pub async fn apply_writes(
Json(input): Json<ApplyWritesInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -154,20 +157,22 @@ pub async fn apply_writes(
.into_response();
}
};
let root_cid_str: String =
match sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await
{
Ok(Some(cid_str)) => cid_str,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response();
}
};
let root_cid_str: String = match sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await
{
Ok(Some(cid_str)) => cid_str,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response();
}
};
let current_root_cid = match Cid::from_str(&root_cid_str) {
Ok(c) => c,
Err(_) => {
@@ -178,15 +183,14 @@ pub async fn apply_writes(
.into_response();
}
};
if let Some(swap_commit) = &input.swap_commit {
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
@@ -195,7 +199,7 @@ pub async fn apply_writes(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response()
.into_response();
}
};
let commit = match Commit::from_cbor(&commit_bytes) {
@@ -205,7 +209,7 @@ pub async fn apply_writes(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response()
.into_response();
}
};
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
@@ -220,11 +224,10 @@ pub async fn apply_writes(
rkey,
value,
} => {
if input.validate.unwrap_or(true) {
if let Err(err_response) = validate_record(value, collection) {
return err_response;
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(value, collection) {
return *err_response;
}
}
let rkey = rkey
.clone()
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
@@ -234,7 +237,13 @@ pub async fn apply_writes(
}
let record_cid = match tracking_store.put(&record_bytes).await {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(),
Err(_) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": "Failed to store record"}),
),
)
.into_response(),
};
let collection_nsid = match collection.parse::<Nsid>() {
Ok(n) => n,
@@ -244,7 +253,11 @@ pub async fn apply_writes(
modified_keys.push(key.clone());
mst = match mst.add(&key, record_cid).await {
Ok(m) => m,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
Err(_) => return (
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 {
@@ -262,18 +275,23 @@ pub async fn apply_writes(
rkey,
value,
} => {
if input.validate.unwrap_or(true) {
if let Err(err_response) = validate_record(value, collection) {
return err_response;
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(value, collection) {
return *err_response;
}
}
let mut record_bytes = Vec::new();
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(),
Err(_) => return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({"error": "InternalError", "message": "Failed to store record"}),
),
)
.into_response(),
};
let collection_nsid = match collection.parse::<Nsid>() {
Ok(n) => n,
@@ -284,7 +302,11 @@ pub async fn apply_writes(
let prev_record_cid = mst.get(&key).await.ok().flatten();
mst = match mst.update(&key, record_cid).await {
Ok(m) => m,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(),
Err(_) => return (
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 {
@@ -321,14 +343,24 @@ pub async fn apply_writes(
}
let new_mst_root = match mst.persist().await {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
}
};
let mut relevant_blocks = std::collections::BTreeMap::new();
for key in &modified_keys {
if let Err(_) = mst.blocks_for_path(key, &mut relevant_blocks).await {
if mst.blocks_for_path(key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if let Err(_) = original_mst.blocks_for_path(key, &mut relevant_blocks).await {
if original_mst
.blocks_for_path(key, &mut relevant_blocks)
.await
.is_err()
{
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
}
@@ -344,13 +376,15 @@ pub async fn apply_writes(
.collect::<Vec<_>>();
let commit_res = match commit_and_log(
&state,
&did,
user_id,
Some(current_root_cid),
Some(commit.data),
new_mst_root,
ops,
&written_cids_str,
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops,
blocks_cids: &written_cids_str,
},
)
.await
{
+61 -20
View File
@@ -1,12 +1,12 @@
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::api::repo::record::write::prepare_repo_write;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use cid::Cid;
use jacquard::types::string::Nsid;
@@ -38,32 +38,45 @@ pub async fn delete_record(
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Some(swap_commit) = &input.swap_commit {
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
};
let mst = Mst::load(
Arc::new(tracking_store.clone()),
commit.data,
None,
);
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
};
let key = format!("{}/{}", collection_nsid, input.rkey);
if let Some(swap_record_str) = &input.swap_record {
@@ -88,15 +101,23 @@ pub async fn delete_record(
Ok(c) => c,
Err(e) => {
error!("Failed to persist MST: {:?}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
}
};
let op = RecordOp::Delete { collection: input.collection, rkey: input.rkey, prev: prev_record_cid };
let op = RecordOp::Delete {
collection: input.collection,
rkey: input.rkey,
prev: prev_record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
let mut written_cids = tracking_store.get_all_relevant_cids();
@@ -105,9 +126,29 @@ pub async fn delete_record(
written_cids.push(*cid);
}
}
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
let written_cids_str = written_cids
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>();
if let Err(e) = commit_and_log(
&state,
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
},
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
};
(StatusCode::OK, Json(json!({}))).into_response()
}
+1 -1
View File
@@ -11,5 +11,5 @@ pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record,
pub use utils::*;
pub use write::{
CreateRecordInput, CreateRecordOutput, PutRecordInput, PutRecordOutput, create_record,
put_record, prepare_repo_write,
prepare_repo_write, put_record,
};
+10 -9
View File
@@ -71,15 +71,14 @@ pub async fn get_record(
.into_response();
}
};
if let Some(expected_cid) = &input.cid {
if &record_cid_str != expected_cid {
if let Some(expected_cid) = &input.cid
&& &record_cid_str != expected_cid {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
)
.into_response();
}
}
let cid = match Cid::from_str(&record_cid_str) {
Ok(c) => c,
Err(_) => {
@@ -192,7 +191,11 @@ pub async fn list_records(
param_idx += 1;
}
if input.rkey_end.is_some() {
conditions.push(if param_idx == 3 { "rkey < $3" } else { "rkey < $4" });
conditions.push(if param_idx == 3 {
"rkey < $3"
} else {
"rkey < $4"
});
param_idx += 1;
}
let limit_idx = param_idx;
@@ -246,17 +249,15 @@ pub async fn list_records(
};
let mut records = Vec::new();
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
if let Some(block) = block_opt {
if let Some((rkey, cid_str)) = cid_to_rkey.get(cid) {
if let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
if let Some(block) = block_opt
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
&& let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
records.push(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
"cid": cid_str,
"value": value
}));
}
}
}
}
Json(ListRecordsOutput {
cursor: last_rkey,
+150 -74
View File
@@ -3,7 +3,7 @@ use bytes::Bytes;
use cid::Cid;
use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
@@ -71,9 +71,22 @@ fn create_signed_commit(
}
pub enum RecordOp {
Create { collection: String, rkey: String, cid: Cid },
Update { collection: String, rkey: String, cid: Cid, prev: Option<Cid> },
Delete { collection: String, rkey: String, prev: Option<Cid> },
Create {
collection: String,
rkey: String,
cid: Cid,
},
Update {
collection: String,
rkey: String,
cid: Cid,
prev: Option<Cid>,
},
Delete {
collection: String,
rkey: String,
prev: Option<Cid>,
},
}
pub struct CommitResult {
@@ -81,16 +94,29 @@ pub struct CommitResult {
pub rev: String,
}
pub struct CommitParams<'a> {
pub did: &'a str,
pub user_id: Uuid,
pub current_root_cid: Option<Cid>,
pub prev_data_cid: Option<Cid>,
pub new_mst_root: Cid,
pub ops: Vec<RecordOp>,
pub blocks_cids: &'a [String],
}
pub async fn commit_and_log(
state: &AppState,
did: &str,
user_id: Uuid,
current_root_cid: Option<Cid>,
prev_data_cid: Option<Cid>,
new_mst_root: Cid,
ops: Vec<RecordOp>,
blocks_cids: &[String],
params: CommitParams<'_>,
) -> Result<CommitResult, String> {
let CommitParams {
did,
user_id,
current_root_cid,
prev_data_cid,
new_mst_root,
ops,
blocks_cids,
} = params;
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
@@ -100,20 +126,21 @@ pub async fn commit_and_log(
.map_err(|e| format!("Failed to fetch signing key: {}", e))?;
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
.map_err(|e| format!("Failed to decrypt signing key: {}", e))?;
let signing_key = SigningKey::from_slice(&key_bytes)
.map_err(|e| format!("Invalid signing key: {}", e))?;
let signing_key =
SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?;
let rev = Tid::now(LimitedU32::MIN);
let rev_str = rev.to_string();
let (new_commit_bytes, _sig) = create_signed_commit(
did,
new_mst_root,
&rev_str,
current_root_cid,
&signing_key,
)?;
let new_root_cid = state.block_store.put(&new_commit_bytes).await
let (new_commit_bytes, _sig) =
create_signed_commit(did, new_mst_root, &rev_str, current_root_cid, &signing_key)?;
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
let mut tx = state
.db
.begin()
.await
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
let lock_result = sqlx::query!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT",
@@ -123,28 +150,36 @@ pub async fn commit_and_log(
.await;
match lock_result {
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code().as_deref() == Some("55P03") {
return Err("ConcurrentModification: Another request is modifying this repo".to_string());
if let Some(db_err) = e.as_database_error()
&& db_err.code().as_deref() == Some("55P03") {
return Err(
"ConcurrentModification: Another request is modifying this repo"
.to_string(),
);
}
}
return Err(format!("Failed to acquire repo lock: {}", e));
}
Ok(Some(row)) => {
if let Some(expected_root) = &current_root_cid {
if row.repo_root_cid != expected_root.to_string() {
return Err("ConcurrentModification: Repo has been modified since last read".to_string());
if let Some(expected_root) = &current_root_cid
&& row.repo_root_cid != expected_root.to_string() {
return Err(
"ConcurrentModification: Repo has been modified since last read"
.to_string(),
);
}
}
}
Ok(None) => {
return Err("Repo not found".to_string());
}
}
sqlx::query!("UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), user_id)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (repos): {}", e))?;
sqlx::query!(
"UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2",
new_root_cid.to_string(),
user_id
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (repos): {}", e))?;
let mut upsert_collections: Vec<String> = Vec::new();
let mut upsert_rkeys: Vec<String> = Vec::new();
let mut upsert_cids: Vec<String> = Vec::new();
@@ -152,12 +187,24 @@ pub async fn commit_and_log(
let mut delete_rkeys: Vec<String> = Vec::new();
for op in &ops {
match op {
RecordOp::Create { collection, rkey, cid } | RecordOp::Update { collection, rkey, cid, .. } => {
RecordOp::Create {
collection,
rkey,
cid,
}
| RecordOp::Update {
collection,
rkey,
cid,
..
} => {
upsert_collections.push(collection.clone());
upsert_rkeys.push(rkey.clone());
upsert_cids.push(cid.to_string());
}
RecordOp::Delete { collection, rkey, .. } => {
RecordOp::Delete {
collection, rkey, ..
} => {
delete_collections.push(collection.clone());
delete_rkeys.push(rkey.clone());
}
@@ -197,14 +244,24 @@ pub async fn commit_and_log(
.await
.map_err(|e| format!("DB Error (records batch delete): {}", e))?;
}
let ops_json = ops.iter().map(|op| {
match op {
RecordOp::Create { collection, rkey, cid } => json!({
let ops_json = ops
.iter()
.map(|op| match op {
RecordOp::Create {
collection,
rkey,
cid,
} => json!({
"action": "create",
"path": format!("{}/{}", collection, rkey),
"cid": cid.to_string()
}),
RecordOp::Update { collection, rkey, cid, prev } => {
RecordOp::Update {
collection,
rkey,
cid,
prev,
} => {
let mut obj = json!({
"action": "update",
"path": format!("{}/{}", collection, rkey),
@@ -214,8 +271,12 @@ pub async fn commit_and_log(
obj["prev"] = json!(prev_cid.to_string());
}
obj
},
RecordOp::Delete { collection, rkey, prev } => {
}
RecordOp::Delete {
collection,
rkey,
prev,
} => {
let mut obj = json!({
"action": "delete",
"path": format!("{}/{}", collection, rkey),
@@ -225,9 +286,9 @@ pub async fn commit_and_log(
obj["prev"] = json!(prev_cid.to_string());
}
obj
},
}
}).collect::<Vec<_>>();
}
})
.collect::<Vec<_>>();
let event_type = "commit";
let prev_cid_str = current_root_cid.map(|c| c.to_string());
let prev_data_cid_str = prev_data_cid.map(|c| c.to_string());
@@ -249,13 +310,12 @@ pub async fn commit_and_log(
.fetch_one(&mut *tx)
.await
.map_err(|e| format!("DB Error (repo_seq): {}", e))?;
sqlx::query(
&format!("NOTIFY repo_updates, '{}'", seq_row.seq)
)
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (notify): {}", e))?;
tx.commit().await
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
.execute(&mut *tx)
.await
.map_err(|e| format!("DB Error (notify): {}", e))?;
tx.commit()
.await
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await;
Ok(CommitResult {
@@ -278,16 +338,20 @@ pub async fn create_record_internal(
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "User not found".to_string())?;
let root_cid_str: String =
sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "Repo not found".to_string())?;
let current_root_cid = Cid::from_str(&root_cid_str)
.map_err(|_| "Invalid repo root CID".to_string())?;
let root_cid_str: String = sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await
.map_err(|e| format!("DB error: {}", e))?
.ok_or_else(|| "Repo not found".to_string())?;
let current_root_cid =
Cid::from_str(&root_cid_str).map_err(|_| "Invalid repo root CID".to_string())?;
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = tracking_store.get(&current_root_cid).await
let commit_bytes = tracking_store
.get(&current_root_cid)
.await
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
.ok_or_else(|| "Commit block not found".to_string())?;
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
@@ -296,12 +360,18 @@ pub async fn create_record_internal(
let mut record_bytes = Vec::new();
serde_ipld_dagcbor::to_writer(&mut record_bytes, record)
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
let record_cid = tracking_store.put(&record_bytes).await
let record_cid = tracking_store
.put(&record_bytes)
.await
.map_err(|e| format!("Failed to save record block: {:?}", e))?;
let key = format!("{}/{}", collection, rkey);
let new_mst = mst.add(&key, record_cid).await
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|e| format!("Failed to add to MST: {:?}", e))?;
let new_mst_root = new_mst.persist().await
let new_mst_root = new_mst
.persist()
.await
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
let op = RecordOp::Create {
collection: collection.to_string(),
@@ -309,9 +379,12 @@ pub async fn create_record_internal(
cid: record_cid,
};
let mut relevant_blocks = std::collections::BTreeMap::new();
new_mst.blocks_for_path(&key, &mut relevant_blocks).await
new_mst
.blocks_for_path(&key, &mut relevant_blocks)
.await
.map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?;
mst.blocks_for_path(&key, &mut relevant_blocks).await
mst.blocks_for_path(&key, &mut relevant_blocks)
.await
.map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?;
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
let mut written_cids = tracking_store.get_all_relevant_cids();
@@ -323,14 +396,17 @@ pub async fn create_record_internal(
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
let result = commit_and_log(
state,
did,
user_id,
Some(current_root_cid),
Some(commit.data),
new_mst_root,
vec![op],
&written_cids_str,
).await?;
CommitParams {
did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
},
)
.await?;
let uri = format!("at://{}/{}/{}", did, collection, rkey);
Ok((uri, result.commit_cid))
}
+14 -14
View File
@@ -1,38 +1,38 @@
use crate::validation::{RecordValidator, ValidationError};
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Response> {
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box<Response>> {
let validator = RecordValidator::new();
match validator.validate(record, collection) {
Ok(_) => Ok(()),
Err(ValidationError::MissingType) => Err((
Err(ValidationError::MissingType) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
).into_response()),
Err(ValidationError::TypeMismatch { expected, actual }) => Err((
).into_response())),
Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
).into_response()),
Err(ValidationError::MissingField(field)) => Err((
).into_response())),
Err(ValidationError::MissingField(field)) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
).into_response()),
Err(ValidationError::InvalidField { path, message }) => Err((
).into_response())),
Err(ValidationError::InvalidField { path, message }) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
).into_response()),
Err(ValidationError::InvalidDatetime { path }) => Err((
).into_response())),
Err(ValidationError::InvalidDatetime { path }) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
).into_response()),
Err(e) => Err((
).into_response())),
Err(e) => Err(Box::new((
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
).into_response()),
).into_response())),
}
}
+244 -86
View File
@@ -1,15 +1,18 @@
use super::validation::validate_record;
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use cid::Cid;
use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}};
use jacquard::types::{
integer::LimitedU32,
string::{Nsid, Tid},
};
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -19,7 +22,10 @@ use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
pub async fn has_verified_notification_channel(
db: &PgPool,
did: &str,
) -> Result<bool, sqlx::Error> {
let row = sqlx::query(
r#"
SELECT
@@ -29,7 +35,7 @@ pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result
signal_verified
FROM users
WHERE did = $1
"#
"#,
)
.bind(did)
.fetch_optional(db)
@@ -52,8 +58,9 @@ pub async fn prepare_repo_write(
repo_did: &str,
) -> Result<(String, Uuid, Cid), Response> {
let token = crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
).ok_or_else(|| {
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
@@ -102,7 +109,11 @@ pub async fn prepare_repo_write(
.await
.map_err(|e| {
error!("DB error fetching user: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
})?
.ok_or_else(|| {
(
@@ -111,21 +122,27 @@ pub async fn prepare_repo_write(
)
.into_response()
})?;
let root_cid_str: String =
sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
error!("DB error fetching repo root: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()
})?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response()
})?;
let root_cid_str: String = sqlx::query_scalar!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
error!("DB error fetching repo root: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response()
})?
.ok_or_else(|| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
)
.into_response()
})?;
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -162,62 +179,102 @@ pub async fn create_record(
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Some(swap_commit) = &input.swap_commit {
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
};
let mst = Mst::load(
Arc::new(tracking_store.clone()),
commit.data,
None,
);
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
};
if input.validate.unwrap_or(true) {
if let Err(err_response) = validate_record(&input.record, &input.collection) {
return err_response;
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
}
let rkey = input.rkey.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
};
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
return *err_response;
}
let rkey = input
.rkey
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
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,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
)
.into_response();
}
};
let key = format!("{}/{}", collection_nsid, rkey);
let new_mst = match mst.add(&key, record_cid).await {
Ok(m) => m,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
_ => {
return (
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,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
}
};
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: rkey.clone(),
cid: record_cid,
};
let op = RecordOp::Create { collection: input.collection.clone(), rkey: rkey.clone(), cid: record_cid };
let mut relevant_blocks = std::collections::BTreeMap::new();
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
@@ -227,14 +284,38 @@ pub async fn create_record(
written_cids.push(*cid);
}
}
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
let written_cids_str = written_cids
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>();
if let Err(e) = commit_and_log(
&state,
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
},
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
};
(StatusCode::OK, Json(CreateRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
cid: record_cid.to_string(),
})).into_response()
(
StatusCode::OK,
Json(CreateRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
cid: record_cid.to_string(),
}),
)
.into_response()
}
#[derive(Deserialize)]
#[allow(dead_code)]
@@ -265,35 +346,51 @@ pub async fn put_record(
Ok(res) => res,
Err(err_res) => return err_res,
};
if let Some(swap_commit) = &input.swap_commit {
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"}))).into_response();
if let Some(swap_commit) = &input.swap_commit
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
return (
StatusCode::CONFLICT,
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
)
.into_response();
}
}
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
let commit_bytes = match tracking_store.get(&current_root_cid).await {
Ok(Some(b)) => b,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
)
.into_response();
}
};
let commit = match Commit::from_cbor(&commit_bytes) {
Ok(c) => c,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
)
.into_response();
}
};
let mst = Mst::load(
Arc::new(tracking_store.clone()),
commit.data,
None,
);
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
let collection_nsid = match input.collection.parse::<Nsid>() {
Ok(n) => n,
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidCollection"})),
)
.into_response();
}
};
let key = format!("{}/{}", collection_nsid, input.rkey);
if input.validate.unwrap_or(true) {
if let Err(err_response) = validate_record(&input.record, &input.collection) {
return err_response;
if input.validate.unwrap_or(true)
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
return *err_response;
}
}
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
@@ -304,37 +401,74 @@ pub async fn put_record(
let existing_cid = mst.get(&key).await.ok().flatten();
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
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,
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(),
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
)
.into_response();
}
};
let new_mst = if existing_cid.is_some() {
match mst.update(&key, record_cid).await {
Ok(m) => m,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to update MST"})),
)
.into_response();
}
}
} else {
match mst.add(&key, record_cid).await {
Ok(m) => m,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
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(),
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
)
.into_response();
}
};
let op = if existing_cid.is_some() {
RecordOp::Update { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid, prev: existing_cid }
RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
prev: existing_cid,
}
} else {
RecordOp::Create { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid }
RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
cid: record_cid,
}
};
let mut relevant_blocks = std::collections::BTreeMap::new();
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
}
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
}
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
@@ -344,12 +478,36 @@ pub async fn put_record(
written_cids.push(*cid);
}
}
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
let written_cids_str = written_cids
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>();
if let Err(e) = commit_and_log(
&state,
CommitParams {
did: &did,
user_id,
current_root_cid: Some(current_root_cid),
prev_data_cid: Some(commit.data),
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
},
)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": e})),
)
.into_response();
};
(StatusCode::OK, Json(PutRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
cid: record_cid.to_string(),
})).into_response()
(
StatusCode::OK,
Json(PutRecordOutput {
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
cid: record_cid.to_string(),
}),
)
.into_response()
}
+67 -34
View File
@@ -32,14 +32,16 @@ pub async fn check_account_status(
headers: axum::http::HeaderMap,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!("https://{}/xrpc/com.atproto.server.checkAccountStatus",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.checkAccountStatus",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
@@ -48,7 +50,9 @@ pub async fn check_account_status(
"GET",
&http_uri,
true,
).await {
)
.await
{
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
@@ -72,24 +76,30 @@ pub async fn check_account_status(
Ok(Some(row)) => row.deactivated_at,
_ => None,
};
let repo_result = sqlx::query!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
.fetch_optional(&state.db)
.await;
let repo_result = sqlx::query!(
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await;
let repo_commit = match repo_result {
Ok(Some(row)) => row.repo_root_cid,
_ => String::new(),
};
let record_count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id)
.fetch_one(&state.db)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let blob_count: i64 =
sqlx::query_scalar!("SELECT COUNT(*) FROM blobs WHERE created_by_user = $1", user_id)
let record_count: i64 =
sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id)
.fetch_one(&state.db)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let blob_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM blobs WHERE created_by_user = $1",
user_id
)
.fetch_one(&state.db)
.await
.unwrap_or(Some(0))
.unwrap_or(0);
let valid_did = did.starts_with("did:");
(
StatusCode::OK,
@@ -113,14 +123,16 @@ pub async fn activate_account(
headers: axum::http::HeaderMap,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!("https://{}/xrpc/com.atproto.server.activateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.activateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
@@ -129,7 +141,9 @@ pub async fn activate_account(
"POST",
&http_uri,
true,
).await {
)
.await
{
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
@@ -171,14 +185,16 @@ pub async fn deactivate_account(
Json(_input): Json<DeactivateAccountInput>,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!("https://{}/xrpc/com.atproto.server.deactivateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.deactivateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
@@ -187,7 +203,9 @@ pub async fn deactivate_account(
"POST",
&http_uri,
false,
).await {
)
.await
{
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
@@ -196,9 +214,12 @@ pub async fn deactivate_account(
.await
.ok()
.flatten();
let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did)
.execute(&state.db)
.await;
let result = sqlx::query!(
"UPDATE users SET deactivated_at = NOW() WHERE did = $1",
did
)
.execute(&state.db)
.await;
match result {
Ok(_) => {
if let Some(h) = handle {
@@ -222,14 +243,16 @@ pub async fn request_account_delete(
headers: axum::http::HeaderMap,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!("https://{}/xrpc/com.atproto.server.requestAccountDelete",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
&state.db,
&extracted.token,
@@ -238,7 +261,9 @@ pub async fn request_account_delete(
"POST",
&http_uri,
true,
).await {
)
.await
{
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
@@ -274,8 +299,13 @@ pub async fn request_account_delete(
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
crate::notifications::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname).await
if let Err(e) = crate::notifications::enqueue_account_deletion(
&state.db,
user_id,
&confirmation_token,
&hostname,
)
.await
{
warn!("Failed to enqueue account deletion notification: {:?}", e);
}
@@ -395,9 +425,12 @@ pub async fn delete_account(
.into_response();
}
if Utc::now() > expires_at {
let _ = sqlx::query!("DELETE FROM account_deletion_requests WHERE token = $1", token)
.execute(&state.db)
.await;
let _ = sqlx::query!(
"DELETE FROM account_deletion_requests WHERE token = $1",
token
)
.execute(&state.db)
.await;
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
+6 -2
View File
@@ -80,7 +80,10 @@ pub async fn create_app_password(
Json(input): Json<CreateAppPasswordInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
.await
{
warn!(ip = %client_ip, "App password creation rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
@@ -88,7 +91,8 @@ pub async fn create_app_password(
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
)
.into_response();
}
let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
Ok(id) => id,
+37 -30
View File
@@ -27,7 +27,10 @@ pub async fn request_email_update(
Json(input): Json<RequestEmailUpdateInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
.await
{
warn!(ip = %client_ip, "Email update rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -35,10 +38,11 @@ pub async fn request_email_update(
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -108,12 +112,7 @@ pub async fn request_email_update(
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::notifications::enqueue_email_update(
&state.db,
user_id,
&email,
&handle,
&code,
&hostname,
&state.db, user_id, &email, &handle, &code, &hostname,
)
.await
{
@@ -136,7 +135,10 @@ pub async fn confirm_email(
Json(input): Json<ConfirmEmailInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
.await
{
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -144,10 +146,11 @@ pub async fn confirm_email(
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
)
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -185,16 +188,19 @@ pub async fn confirm_email(
let email_pending_verification = user.email_pending_verification;
let email = input.email.trim().to_lowercase();
let confirmation_code = input.token.trim();
let (pending_email, saved_code, expiry) = match (email_pending_verification, stored_code, expires_at) {
(Some(p), Some(c), Some(e)) => (p, c, e),
_ => {
return (
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"})),
Json(
json!({"error": "InvalidRequest", "message": "No pending email update found"}),
),
)
.into_response();
}
};
}
};
if pending_email != email {
return (
StatusCode::BAD_REQUEST,
@@ -203,7 +209,7 @@ pub async fn confirm_email(
.into_response();
}
if saved_code != confirmation_code {
return (
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
@@ -225,13 +231,16 @@ pub async fn confirm_email(
.await;
if let Err(e) = update {
error!("DB error finalizing email update: {:?}", e);
if e.as_database_error().map(|db_err| db_err.is_unique_violation()).unwrap_or(false) {
return (
if e.as_database_error()
.map(|db_err| db_err.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
)
.into_response();
}
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -257,7 +266,7 @@ pub async fn update_email(
Json(input): Json<UpdateEmailInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
@@ -302,11 +311,10 @@ pub async fn update_email(
)
.into_response();
}
if let Some(ref current) = current_email {
if new_email == current.to_lowercase() {
if let Some(ref current) = current_email
&& new_email == current.to_lowercase() {
return (StatusCode::OK, Json(json!({}))).into_response();
}
}
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
if email_confirmed {
let confirmation_token = match &input.token {
@@ -353,15 +361,14 @@ pub async fn update_email(
)
.into_response();
}
if let Some(exp) = expires_at {
if Utc::now() > exp {
if let Some(exp) = expires_at
&& Utc::now() > exp {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
}
}
let exists = sqlx::query!(
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
+16 -12
View File
@@ -143,17 +143,18 @@ pub async fn create_invite_codes(
});
} else {
for account_did in for_accounts {
let target_user_id = match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row.id,
Ok(None) => continue,
Err(e) => {
error!("DB error looking up target account: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let target_user_id =
match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row.id,
Ok(None) => continue,
Err(e) => {
error!("DB error looking up target account: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let mut codes = Vec::new();
for _ in 0..code_count {
let code = Uuid::new_v4().to_string();
@@ -177,7 +178,10 @@ pub async fn create_invite_codes(
});
}
}
Json(CreateInviteCodesOutput { codes: result_codes }).into_response()
Json(CreateInviteCodesOutput {
codes: result_codes,
})
.into_response()
}
#[derive(Deserialize)]
+4 -1
View File
@@ -18,5 +18,8 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
pub use meta::{describe_server, health, robots_txt};
pub use password::{request_password_reset, reset_password};
pub use service_auth::get_service_auth;
pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
pub use session::{
confirm_signup, create_session, delete_session, get_session, refresh_session,
resend_verification,
};
pub use signing_key::reserve_signing_key;
+27 -20
View File
@@ -5,7 +5,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use bcrypt::{hash, DEFAULT_COST};
use bcrypt::{DEFAULT_COST, hash};
use chrono::{Duration, Utc};
use serde::Deserialize;
use serde_json::json;
@@ -15,18 +15,15 @@ fn generate_reset_code() -> String {
crate::util::generate_token_code()
}
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"unknown".to_string()
}
@@ -41,7 +38,10 @@ pub async fn request_password_reset(
Json(input): Json<RequestPasswordResetInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::PasswordReset, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
.await
{
warn!(ip = %client_ip, "Password reset rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -118,7 +118,10 @@ pub async fn reset_password(
Json(input): Json<ResetPasswordInput>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::ResetPassword, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::ResetPassword, &client_ip)
.await
{
warn!(ip = %client_ip, "Reset password rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -126,7 +129,8 @@ pub async fn reset_password(
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
)
.into_response();
}
let token = input.token.trim();
let password = &input.password;
@@ -232,12 +236,9 @@ pub async fn reset_password(
)
.into_response();
}
let user_did = match sqlx::query_scalar!(
"SELECT did FROM users WHERE id = $1",
user_id
)
.fetch_one(&mut *tx)
.await
let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
.fetch_one(&mut *tx)
.await
{
Ok(did) => did,
Err(e) => {
@@ -266,7 +267,10 @@ pub async fn reset_password(
.execute(&mut *tx)
.await
{
error!("Failed to invalidate sessions after password reset: {:?}", e);
error!(
"Failed to invalidate sessions after password reset: {:?}",
e
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -284,7 +288,10 @@ pub async fn reset_password(
for jti in session_jtis {
let cache_key = format!("auth:session:{}:{}", user_did, jti);
if let Err(e) = state.cache.delete(&cache_key).await {
warn!("Failed to invalidate session cache for {}: {:?}", cache_key, e);
warn!(
"Failed to invalidate session cache for {}: {:?}",
cache_key, e
);
}
}
info!("Password reset completed for user {}", user_id);
+25 -14
View File
@@ -28,7 +28,7 @@ pub async fn get_service_auth(
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())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
@@ -39,20 +39,31 @@ pub async fn get_service_auth(
};
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"})),
None => {
return ApiError::AuthenticationFailedMsg(
"OAuth tokens cannot create service auth".into(),
)
.into_response();
.into_response();
}
};
(StatusCode::OK, Json(GetServiceAuthOutput { token: service_token })).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()
}
+92 -60
View File
@@ -14,18 +14,15 @@ use serde_json::json;
use tracing::{error, info, warn};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"unknown".to_string()
}
@@ -60,7 +57,10 @@ pub async fn create_session(
) -> Response {
info!("create_session called");
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::Login, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::Login, &client_ip)
.await
{
warn!(ip = %client_ip, "Login rate limit exceeded");
return (
StatusCode::TOO_MANY_REQUESTS,
@@ -88,9 +88,13 @@ pub async fn create_session(
{
Ok(Some(row)) => row,
Ok(None) => {
let _ = verify(&input.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK");
let _ = verify(
&input.password,
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
);
warn!("User not found for login attempt");
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
.into_response();
}
Err(e) => {
error!("Database error fetching user: {:?}", e);
@@ -114,16 +118,17 @@ pub async fn create_session(
.fetch_all(&state.db)
.await
.unwrap_or_default();
app_passwords.iter().any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
app_passwords
.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();
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
.into_response();
}
let is_verified = row.email_confirmed
|| row.discord_verified
|| row.telegram_verified
|| row.signal_verified;
let is_verified =
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
if !is_verified {
warn!("Login attempt for unverified account: {}", row.did);
return (
@@ -133,7 +138,8 @@ pub async fn create_session(
"message": "Please verify your account before logging in",
"did": row.did
})),
).into_response();
)
.into_response();
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
Ok(m) => m,
@@ -169,7 +175,8 @@ pub async fn create_session(
refresh_jwt: refresh_meta.token,
handle: full_handle,
did: row.did,
}).into_response()
})
.into_response()
}
pub async fn get_session(
@@ -220,7 +227,7 @@ pub async fn delete_session(
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())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
@@ -254,7 +261,10 @@ pub async fn refresh_session(
headers: axum::http::HeaderMap,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::RefreshSession, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::RefreshSession, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
@@ -262,17 +272,21 @@ pub async fn refresh_session(
"error": "RateLimitExceeded",
"message": "Too many requests. Please try again later."
})),
).into_response();
)
.into_response();
}
let refresh_token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
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(),
Err(_) => {
return ApiError::AuthenticationFailedMsg("Invalid token format".into())
.into_response();
}
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
@@ -288,12 +302,18 @@ pub async fn refresh_session(
.fetch_optional(&mut *tx)
.await
{
warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id);
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(&mut *tx)
.await;
let _ = tx.commit().await;
return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response();
return ApiError::ExpiredTokenMsg(
"Refresh token has been revoked due to suspected compromise".into(),
)
.into_response();
}
let session_row = match sqlx::query!(
r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
@@ -308,36 +328,42 @@ pub async fn refresh_session(
.await
{
Ok(Some(row)) => row,
Ok(None) => return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(),
Ok(None) => {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into())
.into_response();
}
Err(e) => {
error!("Database error fetching session: {:?}", e);
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();
}
};
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();
}
};
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,
@@ -482,12 +508,12 @@ pub async fn confirm_signup(
warn!("Invalid verification code for user: {}", input.did);
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
}
if let Some(expires_at) = row.email_confirmation_code_expires_at {
if expires_at < Utc::now() {
if let Some(expires_at) = row.email_confirmation_code_expires_at
&& expires_at < Utc::now() {
warn!("Verification code expired for user: {}", input.did);
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
.into_response();
}
}
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(e) => {
@@ -545,7 +571,10 @@ pub async fn confirm_signup(
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
warn!("Failed to enqueue welcome notification: {:?}", e);
}
let email_confirmed = matches!(row.channel, crate::notifications::NotificationChannel::Email);
let email_confirmed = matches!(
row.channel,
crate::notifications::NotificationChannel::Email
);
let preferred_channel = match row.channel {
crate::notifications::NotificationChannel::Email => "email",
crate::notifications::NotificationChannel::Discord => "discord",
@@ -561,7 +590,8 @@ pub async fn confirm_signup(
email_confirmed,
preferred_channel: preferred_channel.to_string(),
preferred_channel_verified: true,
}).into_response()
})
.into_response()
}
#[derive(Deserialize)]
@@ -597,10 +627,8 @@ pub async fn resend_verification(
return ApiError::InternalError.into_response();
}
};
let is_verified = row.email_confirmed
|| row.discord_verified
|| row.telegram_verified
|| row.signal_verified;
let is_verified =
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
if is_verified {
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
}
@@ -619,7 +647,9 @@ pub async fn resend_verification(
return ApiError::InternalError.into_response();
}
let (channel_str, recipient) = match row.channel {
crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
crate::notifications::NotificationChannel::Email => {
("email", row.email.clone().unwrap_or_default())
}
crate::notifications::NotificationChannel::Discord => {
("discord", row.discord_id.unwrap_or_default())
}
@@ -636,7 +666,9 @@ pub async fn resend_verification(
channel_str,
&recipient,
&verification_code,
).await {
)
.await
{
warn!("Failed to enqueue verification notification: {:?}", e);
}
Json(json!({"success": true})).into_response()
+1 -5
View File
@@ -58,11 +58,7 @@ pub async fn reserve_signing_key(
.await;
match result {
Ok(row) => {
info!(
"Reserved signing key {} for did {:?}",
row.id,
input.did
);
info!("Reserved signing key {} for did {:?}", row.id, input.did);
(
StatusCode::OK,
Json(ReserveSigningKeyOutput {
+11 -15
View File
@@ -1,3 +1,5 @@
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
use crate::state::AppState;
use axum::{
Json,
extract::State,
@@ -6,8 +8,6 @@ use axum::{
};
use serde::Serialize;
use serde_json::json;
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
use crate::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -19,28 +19,24 @@ pub struct CheckSignupQueueOutput {
pub estimated_time_ms: Option<i64>,
}
pub async fn check_signup_queue(
State(state): State<AppState>,
headers: HeaderMap,
) -> Response {
if let Some(token) = extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok())
) {
if let Ok(user) = validate_bearer_token(&state.db, &token).await {
if user.is_oauth {
pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(token) =
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
&& let Ok(user) = validate_bearer_token(&state.db, &token).await
&& user.is_oauth {
return (
StatusCode::FORBIDDEN,
Json(json!({
"error": "Forbidden",
"message": "OAuth credentials are not supported for this endpoint"
})),
).into_response();
)
.into_response();
}
}
}
Json(CheckSignupQueueOutput {
activated: true,
place_in_queue: None,
estimated_time_ms: None,
}).into_response()
})
.into_response()
}
+15 -6
View File
@@ -1,13 +1,16 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts, header::AUTHORIZATION},
response::{IntoResponse, Response},
Json,
extract::FromRequestParts,
http::{StatusCode, header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Response},
};
use serde_json::json;
use super::{
AuthenticatedUser, TokenValidationError, validate_bearer_token_cached,
validate_bearer_token_cached_allow_deactivated,
};
use crate::state::AppState;
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated};
pub struct BearerAuth(pub AuthenticatedUser);
@@ -108,7 +111,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
if token.is_empty() {
return None;
}
return Some(ExtractedToken { token: token.to_string(), is_dpop: false });
return Some(ExtractedToken {
token: token.to_string(),
is_dpop: false,
});
}
if header.len() >= 5 && header[..5].eq_ignore_ascii_case("dpop ") {
@@ -116,7 +122,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
if token.is_empty() {
return None;
}
return Some(ExtractedToken { token: token.to_string(), is_dpop: true });
return Some(ExtractedToken {
token: token.to_string(),
is_dpop: true,
});
}
None
+67 -47
View File
@@ -10,15 +10,19 @@ pub mod extractor;
pub mod token;
pub mod verify;
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header, extract_auth_token_from_header, ExtractedToken};
pub use extractor::{
AuthError, BearerAuth, BearerAuthAllowDeactivated, ExtractedToken,
extract_auth_token_from_header, 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,
TokenWithMetadata,
TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE,
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
create_access_token_with_metadata, create_refresh_token, create_refresh_token_with_metadata,
create_service_token,
};
pub use verify::{
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, verify_token,
};
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
const KEY_CACHE_TTL_SECS: u64 = 300;
const SESSION_CACHE_TTL_SECS: u64 = 60;
@@ -113,30 +117,34 @@ async fn validate_bearer_token_with_options_internal(
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref),
None => (None, None, None),
}
} else {
if let Some(user) = sqlx::query!(
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
} else if let Some(user) = sqlx::query!(
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
FROM users u
JOIN user_keys k ON u.id = k.user_id
WHERE u.did = $1",
did
)
.fetch_optional(db)
.await
.ok()
.flatten()
{
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
if let Some(c) = cache {
let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await;
}
(Some(key), user.deactivated_at, user.takedown_ref)
} else {
(None, None, None)
if let Some(c) = cache {
let _ = c
.set_bytes(
&key_cache_key,
&key,
Duration::from_secs(KEY_CACHE_TTL_SECS),
)
.await;
}
(Some(key), user.deactivated_at, user.takedown_ref)
} else {
(None, None, None)
};
if let Some(decrypted_key) = decrypted_key {
@@ -175,11 +183,16 @@ async fn validate_bearer_token_with_options_internal(
session_valid = session_exists.is_some();
if session_valid {
if let Some(c) = cache {
let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await;
if session_valid
&& let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
}
}
}
if session_valid {
@@ -193,8 +206,8 @@ async fn validate_bearer_token_with_options_internal(
}
}
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
if let Some(oauth_token) = sqlx::query!(
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token)
&& let Some(oauth_token) = sqlx::query!(
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref,
k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?"
FROM oauth_token t
@@ -218,7 +231,9 @@ async fn validate_bearer_token_with_options_internal(
let now = chrono::Utc::now();
if oauth_token.expires_at > now {
let key_bytes = if let (Some(kb), Some(ev)) = (&oauth_token.key_bytes, oauth_token.encryption_version) {
let key_bytes = if let (Some(kb), Some(ev)) =
(&oauth_token.key_bytes, oauth_token.encryption_version)
{
crate::config::decrypt_key(kb, Some(ev)).ok()
} else {
None
@@ -230,7 +245,6 @@ async fn validate_bearer_token_with_options_internal(
});
}
}
}
Err(TokenValidationError::AuthenticationFailed)
}
@@ -256,7 +270,15 @@ pub async fn validate_token_with_dpop(
return validate_bearer_token(db, token).await;
}
}
match crate::oauth::verify::verify_oauth_access_token(db, token, dpop_proof, http_method, http_uri).await {
match crate::oauth::verify::verify_oauth_access_token(
db,
token,
dpop_proof,
http_method,
http_uri,
)
.await
{
Ok(result) => {
if !allow_deactivated {
let deactivated = sqlx::query_scalar!(
@@ -272,15 +294,13 @@ pub async fn validate_token_with_dpop(
return Err(TokenValidationError::AccountDeactivated);
}
}
let takedown = sqlx::query_scalar!(
"SELECT takedown_ref FROM users WHERE did = $1",
result.did
)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten();
let takedown =
sqlx::query_scalar!("SELECT takedown_ref FROM users WHERE did = $1", result.did)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten();
if takedown.is_some() {
return Err(TokenValidationError::AccountTakedown);
}
+46 -8
View File
@@ -33,11 +33,26 @@ pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
}
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120))
create_signed_token_with_metadata(
did,
SCOPE_ACCESS,
TOKEN_TYPE_ACCESS,
key_bytes,
Duration::minutes(120),
)
}
pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90))
pub fn create_refresh_token_with_metadata(
did: &str,
key_bytes: &[u8],
) -> Result<TokenWithMetadata> {
create_signed_token_with_metadata(
did,
SCOPE_REFRESH,
TOKEN_TYPE_REFRESH,
key_bytes,
Duration::days(90),
)
}
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
@@ -132,15 +147,38 @@ pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
}
pub fn create_access_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, secret, Duration::minutes(120))
pub fn create_access_token_hs256_with_metadata(
did: &str,
secret: &[u8],
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
did,
SCOPE_ACCESS,
TOKEN_TYPE_ACCESS,
secret,
Duration::minutes(120),
)
}
pub fn create_refresh_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, secret, Duration::days(90))
pub fn create_refresh_token_hs256_with_metadata(
did: &str,
secret: &[u8],
) -> Result<TokenWithMetadata> {
create_hs256_token_with_metadata(
did,
SCOPE_REFRESH,
TOKEN_TYPE_REFRESH,
secret,
Duration::days(90),
)
}
pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]) -> Result<String> {
pub fn create_service_token_hs256(
did: &str,
aud: &str,
lxm: &str,
secret: &[u8],
) -> Result<String> {
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
+22 -12
View File
@@ -1,5 +1,8 @@
use super::token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH,
};
use super::{Claims, Header, TokenData, UnsafeClaims};
use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED};
use anyhow::{Context, Result, anyhow};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
@@ -40,7 +43,8 @@ pub fn get_jti_from_token(token: &str) -> Result<String, String> {
let claims: serde_json::Value =
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
claims.get("jti")
claims
.get("jti")
.and_then(|j| j.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No jti claim in token".to_string())
@@ -108,11 +112,14 @@ fn verify_token_internal(
let header: Header =
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
if let Some(expected) = expected_typ {
if header.typ != expected {
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
if let Some(expected) = expected_typ
&& header.typ != expected {
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
@@ -177,11 +184,14 @@ fn verify_token_hs256_internal(
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
}
if let Some(expected) = expected_typ {
if header.typ != expected {
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
if let Some(expected) = expected_typ
&& header.typ != expected {
return Err(anyhow!(
"Invalid token type: expected {}, got {}",
expected,
header.typ
));
}
}
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
@@ -189,8 +199,8 @@ fn verify_token_hs256_internal(
let message = format!("{}.{}", header_b64, claims_b64);
let mut mac = HmacSha256::new_from_slice(secret)
.map_err(|e| anyhow!("Invalid secret: {}", e))?;
let mut mac =
HmacSha256::new_from_slice(secret).map_err(|e| anyhow!("Invalid secret: {}", e))?;
mac.update(message.as_bytes());
let expected_signature = mac.finalize().into_bytes();
+2 -43
View File
@@ -32,8 +32,7 @@ pub struct ValkeyCache {
impl ValkeyCache {
pub async fn new(url: &str) -> Result<Self, CacheError> {
let client = redis::Client::open(url)
.map_err(|e| CacheError::Connection(e.to_string()))?;
let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
let manager = client
.get_connection_manager()
.await
@@ -118,7 +117,7 @@ impl DistributedRateLimiter for RedisRateLimiter {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
let mut conn = self.conn.clone();
let full_key = format!("rl:{}", key);
let window_secs = ((window_ms + 999) / 1000).max(1) as i64;
let window_secs = window_ms.div_ceil(1000).max(1) as i64;
let count: Result<i64, _> = redis::cmd("INCR")
.arg(&full_key)
.query_async(&mut conn)
@@ -150,46 +149,6 @@ impl DistributedRateLimiter for NoOpRateLimiter {
}
}
pub enum CacheBackend {
Valkey(ValkeyCache),
NoOp,
}
impl CacheBackend {
pub fn rate_limiter(&self) -> Arc<dyn DistributedRateLimiter> {
match self {
CacheBackend::Valkey(cache) => {
Arc::new(RedisRateLimiter::new(cache.connection()))
}
CacheBackend::NoOp => Arc::new(NoOpRateLimiter),
}
}
}
#[async_trait]
impl Cache for CacheBackend {
async fn get(&self, key: &str) -> Option<String> {
match self {
CacheBackend::Valkey(c) => c.get(key).await,
CacheBackend::NoOp => None,
}
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
match self {
CacheBackend::Valkey(c) => c.set(key, value, ttl).await,
CacheBackend::NoOp => Ok(()),
}
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
match self {
CacheBackend::Valkey(c) => c.delete(key).await,
CacheBackend::NoOp => Ok(()),
}
}
}
pub async fn create_cache() -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
match std::env::var("VALKEY_URL") {
Ok(url) => match ValkeyCache::new(&url).await {
+7 -2
View File
@@ -1,5 +1,5 @@
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::RwLock;
@@ -22,7 +22,12 @@ pub struct CircuitBreaker {
}
impl CircuitBreaker {
pub fn new(name: &str, failure_threshold: u32, success_threshold: u32, timeout_secs: u64) -> Self {
pub fn new(
name: &str,
failure_threshold: u32,
success_threshold: u32,
timeout_secs: u64,
) -> Self {
Self {
name: name.to_string(),
failure_threshold,
+16 -9
View File
@@ -1,8 +1,5 @@
#[allow(deprecated)]
use aes_gcm::{
Aes256Gcm, KeyInit, Nonce,
aead::Aead,
};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hkdf::Hkdf;
use p256::ecdsa::SigningKey;
@@ -62,17 +59,25 @@ impl AuthConfig {
hasher.update(jwt_secret.as_bytes());
let seed = hasher.finalize();
let signing_key = SigningKey::from_slice(&seed)
.unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e));
let signing_key = SigningKey::from_slice(&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().expect("EC point missing X coordinate - this should never happen")
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")
point
.y()
.expect("EC point missing Y coordinate - this should never happen"),
);
let mut kid_hasher = Sha256::new();
@@ -114,7 +119,9 @@ impl AuthConfig {
}
pub fn get() -> &'static Self {
CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first")
CONFIG
.get()
.expect("AuthConfig not initialized - call AuthConfig::init() first")
}
pub fn jwt_secret(&self) -> &str {
+7 -5
View File
@@ -1,8 +1,8 @@
use crate::circuit_breaker::CircuitBreaker;
use crate::sync::firehose::SequencedEvent;
use reqwest::Client;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
use tracing::{debug, error, info, warn};
@@ -78,18 +78,20 @@ impl Crawlers {
return;
}
if let Some(cb) = &self.circuit_breaker {
if !cb.can_execute().await {
if let Some(cb) = &self.circuit_breaker
&& !cb.can_execute().await {
debug!("Skipping crawler notification due to circuit breaker open");
return;
}
}
self.mark_notified();
let circuit_breaker = self.circuit_breaker.clone();
for crawler_url in &self.crawler_urls {
let url = format!("{}/xrpc/com.atproto.sync.requestCrawl", crawler_url.trim_end_matches('/'));
let url = format!(
"{}/xrpc/com.atproto.sync.requestCrawl",
crawler_url.trim_end_matches('/')
);
let hostname = self.hostname.clone();
let client = self.http_client.clone();
let cb = circuit_breaker.clone();
+20 -7
View File
@@ -90,7 +90,11 @@ impl ImageProcessor {
self
}
pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> {
pub fn process(
&self,
data: &[u8],
mime_type: &str,
) -> Result<ImageProcessingResult, ImageError> {
if data.len() > self.max_file_size {
return Err(ImageError::FileTooLarge {
size: data.len(),
@@ -107,12 +111,16 @@ impl ImageProcessor {
});
}
let original = self.encode_image(&img)?;
let thumbnail_feed = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) {
let thumbnail_feed = if self.generate_thumbnails
&& (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED)
{
Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?)
} else {
None
};
let thumbnail_full = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) {
let thumbnail_full = if self.generate_thumbnails
&& (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL)
{
Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?)
} else {
None
@@ -183,7 +191,11 @@ impl ImageProcessor {
})
}
fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> {
fn generate_thumbnail(
&self,
img: &DynamicImage,
max_size: u32,
) -> Result<ProcessedImage, ImageError> {
let (orig_width, orig_height) = (img.width(), img.height());
let (new_width, new_height) = if orig_width > orig_height {
let ratio = max_size as f64 / orig_width as f64;
@@ -204,8 +216,8 @@ impl ImageProcessor {
}
pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> {
let format = image::guess_format(data)
.map_err(|e| ImageError::DecodeError(e.to_string()))?;
let format =
image::guess_format(data).map_err(|e| ImageError::DecodeError(e.to_string()))?;
let cursor = Cursor::new(data);
let img = ImageReader::with_format(cursor, format)
.decode()
@@ -224,7 +236,8 @@ mod tests {
fn create_test_image(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
.unwrap();
buf
}
+39 -43
View File
@@ -109,18 +109,9 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.sync.getLatestCommit",
get(sync::get_latest_commit),
)
.route(
"/xrpc/com.atproto.sync.listRepos",
get(sync::list_repos),
)
.route(
"/xrpc/com.atproto.sync.getBlob",
get(sync::get_blob),
)
.route(
"/xrpc/com.atproto.sync.listBlobs",
get(sync::list_blobs),
)
.route("/xrpc/com.atproto.sync.listRepos", get(sync::list_repos))
.route("/xrpc/com.atproto.sync.getBlob", get(sync::get_blob))
.route("/xrpc/com.atproto.sync.listBlobs", get(sync::list_blobs))
.route(
"/xrpc/com.atproto.sync.getRepoStatus",
get(sync::get_repo_status),
@@ -145,26 +136,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.sync.requestCrawl",
post(sync::request_crawl),
)
.route(
"/xrpc/com.atproto.sync.getBlocks",
get(sync::get_blocks),
)
.route(
"/xrpc/com.atproto.sync.getRepo",
get(sync::get_repo),
)
.route(
"/xrpc/com.atproto.sync.getRecord",
get(sync::get_record),
)
.route("/xrpc/com.atproto.sync.getBlocks", get(sync::get_blocks))
.route("/xrpc/com.atproto.sync.getRepo", get(sync::get_repo))
.route("/xrpc/com.atproto.sync.getRecord", get(sync::get_record))
.route(
"/xrpc/com.atproto.sync.subscribeRepos",
get(sync::subscribe_repos),
)
.route(
"/xrpc/com.atproto.sync.getHead",
get(sync::get_head),
)
.route("/xrpc/com.atproto.sync.getHead", get(sync::get_head))
.route(
"/xrpc/com.atproto.sync.getCheckout",
get(sync::get_checkout),
@@ -349,16 +328,16 @@ pub fn app(state: AppState) -> Router {
"/xrpc/app.bsky.feed.getPostThread",
get(api::feed::get_post_thread),
)
.route(
"/xrpc/app.bsky.feed.getFeed",
get(api::feed::get_feed),
)
.route("/xrpc/app.bsky.feed.getFeed", get(api::feed::get_feed))
.route(
"/xrpc/app.bsky.notification.registerPush",
post(api::notification::register_push),
)
.route("/.well-known/did.json", get(api::identity::well_known_did))
.route("/.well-known/atproto-did", get(api::identity::well_known_atproto_did))
.route(
"/.well-known/atproto-did",
get(api::identity::well_known_atproto_did),
)
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
.route(
"/.well-known/oauth-protected-resource",
@@ -375,13 +354,28 @@ pub fn app(state: AppState) -> Router {
)
.route("/oauth/authorize", get(oauth::endpoints::authorize_get))
.route("/oauth/authorize", post(oauth::endpoints::authorize_post))
.route("/oauth/authorize/select", post(oauth::endpoints::authorize_select))
.route("/oauth/authorize/2fa", get(oauth::endpoints::authorize_2fa_get))
.route("/oauth/authorize/2fa", post(oauth::endpoints::authorize_2fa_post))
.route("/oauth/authorize/deny", post(oauth::endpoints::authorize_deny))
.route(
"/oauth/authorize/select",
post(oauth::endpoints::authorize_select),
)
.route(
"/oauth/authorize/2fa",
get(oauth::endpoints::authorize_2fa_get),
)
.route(
"/oauth/authorize/2fa",
post(oauth::endpoints::authorize_2fa_post),
)
.route(
"/oauth/authorize/deny",
post(oauth::endpoints::authorize_deny),
)
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
.route("/oauth/introspect", post(oauth::endpoints::introspect_token))
.route(
"/oauth/introspect",
post(oauth::endpoints::introspect_token),
)
.route(
"/xrpc/com.atproto.temp.checkSignupQueue",
get(api::temp::check_signup_queue),
@@ -404,13 +398,15 @@ pub fn app(state: AppState) -> Router {
)
.with_state(state);
let frontend_dir = std::env::var("FRONTEND_DIR")
.unwrap_or_else(|_| "./frontend/dist".to_string());
let frontend_dir =
std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "./frontend/dist".to_string());
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
if std::path::Path::new(&frontend_dir)
.join("index.html")
.exists()
{
let index_path = format!("{}/index.html", frontend_dir);
let serve_dir = ServeDir::new(&frontend_dir)
.not_found_service(ServeFile::new(index_path));
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(index_path));
router.fallback_service(serve_dir)
} else {
router
+9 -3
View File
@@ -1,5 +1,7 @@
use bspds::crawlers::{Crawlers, start_crawlers_service};
use bspds::notifications::{DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender};
use bspds::notifications::{
DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender,
};
use bspds::state::AppState;
use std::net::SocketAddr;
use std::process::ExitCode;
@@ -94,11 +96,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
let crawlers = Arc::new(
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone())
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()),
);
let firehose_rx = state.firehose_tx.subscribe();
info!("Crawlers notification service enabled");
Some(tokio::spawn(start_crawlers_service(crawlers, firehose_rx, shutdown_rx)))
Some(tokio::spawn(start_crawlers_service(
crawlers,
firehose_rx,
shutdown_rx,
)))
} else {
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
None
+9 -12
View File
@@ -24,10 +24,7 @@ pub fn init_metrics() -> PrometheusHandle {
}
fn describe_metrics() {
metrics::describe_counter!(
"bspds_http_requests_total",
"Total number of HTTP requests"
);
metrics::describe_counter!("bspds_http_requests_total", "Total number of HTTP requests");
metrics::describe_histogram!(
"bspds_http_request_duration_seconds",
"HTTP request duration in seconds"
@@ -64,10 +61,7 @@ fn describe_metrics() {
"bspds_rate_limit_rejections_total",
"Total number of rate limit rejections"
);
metrics::describe_counter!(
"bspds_db_queries_total",
"Total number of database queries"
);
metrics::describe_counter!("bspds_db_queries_total", "Total number of database queries");
metrics::describe_histogram!(
"bspds_db_query_duration_seconds",
"Database query duration in seconds"
@@ -78,7 +72,11 @@ pub async fn metrics_handler() -> impl IntoResponse {
match PROMETHEUS_HANDLE.get() {
Some(handle) => {
let metrics = handle.render();
(StatusCode::OK, [("content-type", "text/plain; version=0.0.4")], metrics)
(
StatusCode::OK,
[("content-type", "text/plain; version=0.0.4")],
metrics,
)
}
None => (
StatusCode::INTERNAL_SERVER_ERROR,
@@ -117,14 +115,13 @@ pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response
}
fn normalize_path(path: &str) -> String {
if path.starts_with("/xrpc/") {
if let Some(method) = path.strip_prefix("/xrpc/") {
if path.starts_with("/xrpc/")
&& let Some(method) = path.strip_prefix("/xrpc/") {
if let Some(q) = method.find('?') {
return format!("/xrpc/{}", &method[..q]);
}
return path.to_string();
}
}
if path.starts_with("/u/") && path.ends_with("/did.json") {
return "/u/{handle}/did.json".to_string();
+3 -3
View File
@@ -8,9 +8,9 @@ pub use sender::{
};
pub use service::{
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset,
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
};
pub use types::{
+14 -19
View File
@@ -80,7 +80,8 @@ impl EmailSender {
Self {
from_address,
from_name,
sendmail_path: std::env::var("SENDMAIL_PATH").unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
sendmail_path: std::env::var("SENDMAIL_PATH")
.unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
}
}
@@ -91,19 +92,21 @@ impl EmailSender {
}
pub fn format_email(&self, notification: &QueuedNotification) -> String {
let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
let subject =
sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
let recipient = sanitize_header_value(&notification.recipient);
let from_header = if self.from_name.is_empty() {
self.from_address.clone()
} else {
format!("{} <{}>", sanitize_header_value(&self.from_name), self.from_address)
format!(
"{} <{}>",
sanitize_header_value(&self.from_name),
self.from_address
)
};
format!(
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
from_header,
recipient,
subject,
notification.body
from_header, recipient, subject, notification.body
)
}
}
@@ -195,7 +198,7 @@ impl NotificationSender for DiscordSender {
Err(e) => {
if e.is_timeout() {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Discord request timed out"));
last_error = Some("Discord request timed out".to_string());
retry_delay(attempt).await;
continue;
}
@@ -243,10 +246,7 @@ impl NotificationSender for TelegramSender {
let chat_id = &notification.recipient;
let subject = notification.subject.as_deref().unwrap_or("Notification");
let text = format!("*{}*\n\n{}", subject, notification.body);
let url = format!(
"https://api.telegram.org/bot{}/sendMessage",
self.bot_token
);
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
let payload = json!({
"chat_id": chat_id,
"text": text,
@@ -254,12 +254,7 @@ impl NotificationSender for TelegramSender {
});
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
let result = self
.http_client
.post(&url)
.json(&payload)
.send()
.await;
let result = self.http_client.post(&url).json(&payload).send().await;
match result {
Ok(response) => {
if response.status().is_success() {
@@ -280,7 +275,7 @@ impl NotificationSender for TelegramSender {
Err(e) => {
if e.is_timeout() {
if attempt < MAX_RETRIES - 1 {
last_error = Some(format!("Telegram request timed out"));
last_error = Some("Telegram request timed out".to_string());
retry_delay(attempt).await;
continue;
}
+7 -2
View File
@@ -80,7 +80,9 @@ impl NotificationService {
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
if self.senders.is_empty() {
warn!("Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured.");
warn!(
"Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured."
);
}
info!(
poll_interval_secs = self.poll_interval.as_secs(),
@@ -231,7 +233,10 @@ impl NotificationService {
}
}
pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
pub async fn enqueue_notification(
db: &PgPool,
notification: NewNotification,
) -> Result<Uuid, sqlx::Error> {
sqlx::query_scalar!(
r#"
INSERT INTO notification_queue
+117 -80
View File
@@ -88,18 +88,15 @@ impl ClientMetadataCache {
fn is_loopback_client(client_id: &str) -> bool {
if let Ok(url) = reqwest::Url::parse(client_id) {
url.scheme() == "http"
&& url.host_str() == Some("localhost")
&& url.port().is_none()
url.scheme() == "http" && url.host_str() == Some("localhost") && url.port().is_none()
} else {
false
}
}
fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> {
let url = reqwest::Url::parse(client_id).map_err(|_| {
OAuthError::InvalidClient("Invalid loopback client_id URL".to_string())
})?;
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".to_string()))?;
let mut redirect_uris = Vec::new();
for (key, value) in url.query_pairs() {
if key == "redirect_uri" {
@@ -117,7 +114,10 @@ impl ClientMetadataCache {
client_uri: None,
logo_uri: None,
redirect_uris,
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
grant_types: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope,
token_endpoint_auth_method: Some("none".to_string()),
@@ -134,11 +134,10 @@ impl ClientMetadataCache {
}
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(client_id) {
if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
if let Some(cached) = cache.get(client_id)
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
return Ok(cached.metadata.clone());
}
}
}
let metadata = self.fetch_metadata(client_id).await?;
{
@@ -154,7 +153,10 @@ impl ClientMetadataCache {
Ok(metadata)
}
pub async fn get_jwks(&self, metadata: &ClientMetadata) -> Result<serde_json::Value, OAuthError> {
pub async fn get_jwks(
&self,
metadata: &ClientMetadata,
) -> Result<serde_json::Value, OAuthError> {
if let Some(jwks) = &metadata.jwks {
return Ok(jwks.clone());
}
@@ -165,11 +167,10 @@ impl ClientMetadataCache {
})?;
{
let cache = self.jwks_cache.read().await;
if let Some(cached) = cache.get(jwks_uri) {
if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
if let Some(cached) = cache.get(jwks_uri)
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
return Ok(cached.jwks.clone());
}
}
}
let jwks = self.fetch_jwks(jwks_uri).await?;
{
@@ -186,15 +187,14 @@ impl ClientMetadataCache {
}
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
if !jwks_uri.starts_with("https://") {
if !jwks_uri.starts_with("http://")
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1"))
if !jwks_uri.starts_with("https://")
&& (!jwks_uri.starts_with("http://")
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
{
return Err(OAuthError::InvalidClient(
"jwks_uri must use https (except for localhost)".to_string(),
));
}
}
let response = self
.http_client
.get(jwks_uri)
@@ -242,17 +242,18 @@ impl ClientMetadataCache {
.header("Accept", "application/json")
.send()
.await
.map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?;
.map_err(|e| {
OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e))
})?;
if !response.status().is_success() {
return Err(OAuthError::InvalidClient(format!(
"Failed to fetch client metadata: HTTP {}",
response.status()
)));
}
let mut metadata: ClientMetadata = response
.json()
.await
.map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?;
let mut metadata: ClientMetadata = response.json().await.map_err(|e| {
OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e))
})?;
if metadata.client_id.is_empty() {
metadata.client_id = client_id.to_string();
} else if metadata.client_id != client_id {
@@ -274,7 +275,9 @@ impl ClientMetadataCache {
self.validate_redirect_uri_format(uri)?;
}
if !metadata.grant_types.is_empty()
&& !metadata.grant_types.contains(&"authorization_code".to_string())
&& !metadata
.grant_types
.contains(&"authorization_code".to_string())
{
return Err(OAuthError::InvalidClient(
"authorization_code grant type is required".to_string(),
@@ -298,8 +301,8 @@ impl ClientMetadataCache {
if metadata.redirect_uris.contains(&redirect_uri.to_string()) {
return Ok(());
}
if Self::is_loopback_client(&metadata.client_id) {
if let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
if Self::is_loopback_client(&metadata.client_id)
&& let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
let req_host = req_url.host_str().unwrap_or("");
let is_loopback_redirect = req_url.scheme() == "http"
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
@@ -319,7 +322,6 @@ impl ClientMetadataCache {
}
}
}
}
Err(OAuthError::InvalidRequest(
"redirect_uri not registered for client".to_string(),
))
@@ -331,9 +333,8 @@ impl ClientMetadataCache {
"redirect_uri must not contain a fragment".to_string(),
));
}
let parsed = reqwest::Url::parse(uri).map_err(|_| {
OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri))
})?;
let parsed = reqwest::Url::parse(uri)
.map_err(|_| OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)))?;
let scheme = parsed.scheme();
if scheme == "http" {
let host = parsed.host_str().unwrap_or("");
@@ -343,8 +344,15 @@ impl ClientMetadataCache {
));
}
} else if scheme == "https" {
} else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') {
if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) {
} else if scheme.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-'
}) {
if !scheme
.chars()
.next()
.map(|c| c.is_ascii_lowercase())
.unwrap_or(false)
{
return Err(OAuthError::InvalidClient(format!(
"Invalid redirect_uri scheme: {}",
scheme
@@ -366,9 +374,7 @@ impl ClientMetadata {
}
pub fn auth_method(&self) -> &str {
self.token_endpoint_auth_method
.as_deref()
.unwrap_or("none")
self.token_endpoint_auth_method.as_deref().unwrap_or("none")
}
}
@@ -411,10 +417,15 @@ async fn verify_private_key_jwt_async(
metadata: &ClientMetadata,
client_assertion: &str,
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD, STANDARD}};
use base64::{
Engine as _,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
let parts: Vec<&str> = client_assertion.split('.').collect();
if parts.len() != 3 {
return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string()));
return Err(OAuthError::InvalidClient(
"Invalid client_assertion format".to_string(),
));
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
@@ -422,10 +433,14 @@ async fn verify_private_key_jwt_async(
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing alg in client_assertion".to_string())
})?;
if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") {
let alg = header
.get("alg")
.and_then(|a| a.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing alg in client_assertion".to_string()))?;
if !matches!(
alg,
"ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA"
) {
return Err(OAuthError::InvalidClient(format!(
"Unsupported client_assertion algorithm: {}",
alg
@@ -441,17 +456,19 @@ async fn verify_private_key_jwt_async(
})?;
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing iss in client_assertion".to_string())
})?;
let iss = payload
.get("iss")
.and_then(|i| i.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing iss in client_assertion".to_string()))?;
if iss != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion iss does not match client_id".to_string(),
));
}
let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing sub in client_assertion".to_string())
})?;
let sub = payload
.get("sub")
.and_then(|s| s.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing sub in client_assertion".to_string()))?;
if sub != metadata.client_id {
return Err(OAuthError::InvalidClient(
"client_assertion sub does not match client_id".to_string(),
@@ -462,30 +479,38 @@ async fn verify_private_key_jwt_async(
let iat = payload.get("iat").and_then(|i| i.as_i64());
if let Some(exp) = exp {
if exp < now {
return Err(OAuthError::InvalidClient("client_assertion has expired".to_string()));
return Err(OAuthError::InvalidClient(
"client_assertion has expired".to_string(),
));
}
} else if let Some(iat) = iat {
let max_age_secs = 300;
if now - iat > max_age_secs {
tracing::warn!(iat = iat, now = now, "client_assertion too old (no exp, using iat)");
return Err(OAuthError::InvalidClient("client_assertion is too old".to_string()));
tracing::warn!(
iat = iat,
now = now,
"client_assertion too old (no exp, using iat)"
);
return Err(OAuthError::InvalidClient(
"client_assertion is too old".to_string(),
));
}
} else {
return Err(OAuthError::InvalidClient(
"client_assertion must have exp or iat claim".to_string(),
));
}
if let Some(iat) = iat {
if iat > now + 60 {
if let Some(iat) = iat
&& iat > now + 60 {
return Err(OAuthError::InvalidClient(
"client_assertion iat is in the future".to_string(),
));
}
}
let jwks = cache.get_jwks(metadata).await?;
let keys = jwks.get("keys").and_then(|k| k.as_array()).ok_or_else(|| {
OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string())
})?;
let keys = jwks
.get("keys")
.and_then(|k| k.as_array())
.ok_or_else(|| OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()))?;
let matching_keys: Vec<&serde_json::Value> = if let Some(kid) = kid {
keys.iter()
.filter(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid))
@@ -532,17 +557,21 @@ fn verify_es256(
signature: &[u8],
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
use p256::EncodedPoint;
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing x coordinate in EC key".to_string())
})?;
let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing y coordinate in EC key".to_string())
})?;
let x_bytes = URL_SAFE_NO_PAD.decode(x)
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
let y = key
.get("y")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
let x_bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD.decode(y)
let y_bytes = URL_SAFE_NO_PAD
.decode(y)
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
let mut point_bytes = vec![0x04];
point_bytes.extend_from_slice(&x_bytes);
@@ -564,17 +593,21 @@ fn verify_es384(
signature: &[u8],
) -> Result<(), OAuthError> {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
use p384::EncodedPoint;
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing x coordinate in EC key".to_string())
})?;
let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing y coordinate in EC key".to_string())
})?;
let x_bytes = URL_SAFE_NO_PAD.decode(x)
use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
let y = key
.get("y")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
let x_bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD.decode(y)
let y_bytes = URL_SAFE_NO_PAD
.decode(y)
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
let mut point_bytes = vec![0x04];
point_bytes.extend_from_slice(&x_bytes);
@@ -615,16 +648,20 @@ fn verify_eddsa(
crv
)));
}
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
OAuthError::InvalidClient("Missing x in OKP key".to_string())
})?;
let x_bytes = URL_SAFE_NO_PAD.decode(x)
let x = key
.get("x")
.and_then(|v| v.as_str())
.ok_or_else(|| OAuthError::InvalidClient("Missing x in OKP key".to_string()))?;
let x_bytes = URL_SAFE_NO_PAD
.decode(x)
.map_err(|_| OAuthError::InvalidClient("Invalid x encoding".to_string()))?;
let key_bytes: [u8; 32] = x_bytes.try_into()
let key_bytes: [u8; 32] = x_bytes
.try_into()
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key length".to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key".to_string()))?;
let sig_bytes: [u8; 64] = signature.try_into()
let sig_bytes: [u8; 64] = signature
.try_into()
.map_err(|_| OAuthError::InvalidClient("Invalid EdDSA signature length".to_string()))?;
let sig = Signature::from_bytes(&sig_bytes);
verifying_key
+1 -1
View File
@@ -1,6 +1,6 @@
use sqlx::PgPool;
use super::super::{AuthorizedClientData, OAuthError};
use super::helpers::{from_json, to_json};
use sqlx::PgPool;
pub async fn upsert_authorized_client(
pool: &PgPool,
+2 -5
View File
@@ -1,6 +1,6 @@
use super::super::{DeviceData, OAuthError};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::super::{DeviceData, OAuthError};
pub struct DeviceAccountRow {
pub did: String,
@@ -49,10 +49,7 @@ pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceD
}))
}
pub async fn update_device_last_seen(
pool: &PgPool,
device_id: &str,
) -> Result<(), OAuthError> {
pub async fn update_device_last_seen(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
UPDATE oauth_device
+2 -5
View File
@@ -1,10 +1,7 @@
use sqlx::PgPool;
use super::super::OAuthError;
use sqlx::PgPool;
pub async fn check_and_record_dpop_jti(
pool: &PgPool,
jti: &str,
) -> Result<bool, 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)
+1 -1
View File
@@ -1,5 +1,5 @@
use serde::{de::DeserializeOwned, Serialize};
use super::super::OAuthError;
use serde::{Serialize, de::DeserializeOwned};
pub fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> {
serde_json::to_value(value).map_err(|e| {
+5 -5
View File
@@ -8,8 +8,8 @@ mod two_factor;
pub use client::{get_authorized_client, upsert_authorized_client};
pub use device::{
create_device, delete_device, get_device, get_device_accounts, update_device_last_seen,
upsert_account_device, verify_account_on_device, DeviceAccountRow,
DeviceAccountRow, create_device, delete_device, get_device, get_device_accounts,
update_device_last_seen, upsert_account_device, verify_account_on_device,
};
pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
pub use request::{
@@ -23,7 +23,7 @@ pub use token::{
get_token_by_refresh_token, list_tokens_for_user, rotate_token,
};
pub use two_factor::{
check_user_2fa_enabled, cleanup_expired_2fa_challenges, create_2fa_challenge,
delete_2fa_challenge, delete_2fa_challenge_by_request_uri, generate_2fa_code,
get_2fa_challenge, increment_2fa_attempts, TwoFactorChallenge,
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
create_2fa_challenge, delete_2fa_challenge, delete_2fa_challenge_by_request_uri,
generate_2fa_code, get_2fa_challenge, increment_2fa_attempts,
};
+1 -1
View File
@@ -1,6 +1,6 @@
use sqlx::PgPool;
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
use super::helpers::{from_json, to_json};
use sqlx::PgPool;
pub async fn create_authorization_request(
pool: &PgPool,
+4 -10
View File
@@ -1,12 +1,9 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use super::super::{OAuthError, TokenData};
use super::helpers::{from_json, to_json};
use chrono::{DateTime, Utc};
use sqlx::PgPool;
pub async fn create_token(
pool: &PgPool,
data: &TokenData,
) -> Result<i32, OAuthError> {
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!(
@@ -193,10 +190,7 @@ pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthE
Ok(())
}
pub async fn list_tokens_for_user(
pool: &PgPool,
did: &str,
) -> Result<Vec<TokenData>, OAuthError> {
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,
+1 -1
View File
@@ -1,8 +1,8 @@
use super::super::OAuthError;
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use sqlx::PgPool;
use uuid::Uuid;
use super::super::OAuthError;
pub struct TwoFactorChallenge {
pub id: Uuid,
+79 -48
View File
@@ -61,7 +61,7 @@ impl DPoPVerifier {
let timestamp_bytes = timestamp.to_be_bytes();
let mut hasher = Sha256::new();
hasher.update(&self.secret);
hasher.update(&timestamp_bytes);
hasher.update(timestamp_bytes);
let hash = hasher.finalize();
let mut nonce_data = Vec::with_capacity(8 + 16);
nonce_data.extend_from_slice(&timestamp_bytes);
@@ -74,7 +74,9 @@ impl DPoPVerifier {
.decode(nonce)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?;
if nonce_bytes.len() < 24 {
return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Invalid nonce length".to_string(),
));
}
let timestamp_bytes: [u8; 8] = nonce_bytes[..8]
.try_into()
@@ -86,10 +88,12 @@ impl DPoPVerifier {
}
let mut hasher = Sha256::new();
hasher.update(&self.secret);
hasher.update(&timestamp_bytes);
hasher.update(timestamp_bytes);
let expected_hash = hasher.finalize();
if nonce_bytes[8..24] != expected_hash[..16] {
return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Invalid nonce signature".to_string(),
));
}
Ok(())
}
@@ -103,7 +107,9 @@ impl DPoPVerifier {
) -> Result<DPoPVerifyResult, OAuthError> {
let parts: Vec<&str> = dpop_header.split('.').collect();
if parts.len() != 3 {
return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Invalid DPoP proof format".to_string(),
));
}
let header_json = URL_SAFE_NO_PAD
.decode(parts[0])
@@ -116,22 +122,32 @@ impl DPoPVerifier {
let payload: DPoPProofPayload = serde_json::from_slice(&payload_json)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?;
if header.typ != "dpop+jwt" {
return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Invalid typ claim".to_string(),
));
}
if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") {
return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Unsupported algorithm".to_string(),
));
}
if payload.htm.to_uppercase() != http_method.to_uppercase() {
return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string()));
return Err(OAuthError::InvalidDpopProof(
"HTTP method mismatch".to_string(),
));
}
let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu);
let request_uri = http_uri.split('?').next().unwrap_or(http_uri);
if proof_uri != request_uri {
return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string()));
return Err(OAuthError::InvalidDpopProof(
"HTTP URI mismatch".to_string(),
));
}
let now = Utc::now().timestamp();
if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS {
return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string()));
return Err(OAuthError::InvalidDpopProof(
"Proof too old or from the future".to_string(),
));
}
if let Some(nonce) = &payload.nonce {
self.validate_nonce(nonce)?;
@@ -155,7 +171,12 @@ impl DPoPVerifier {
.decode(parts[2])
.map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?;
let signing_input = format!("{}.{}", parts[0], parts[1]);
verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?;
verify_dpop_signature(
&header.alg,
&header.jwk,
signing_input.as_bytes(),
&signature_bytes,
)?;
let jkt = compute_jwk_thumbprint(&header.jwk)?;
Ok(DPoPVerifyResult {
jkt,
@@ -186,9 +207,10 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
use p256::ecdsa::{Signature, VerifyingKey};
use p256::elliptic_curve::sec1::FromEncodedPoint;
use p256::{AffinePoint, EncodedPoint};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for ES256".to_string())
})?;
let crv = jwk
.crv
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()))?;
if crv != "P-256" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for ES256: {}",
@@ -196,14 +218,18 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.decode(
jwk.x
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD
.decode(jwk.y.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
})?)
.decode(
jwk.y
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
let point = EncodedPoint::from_affine_coordinates(
x_bytes.as_slice().into(),
@@ -211,8 +237,8 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
false,
);
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 affine =
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
let verifying_key = VerifyingKey::from_affine(affine)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
let sig = Signature::from_slice(signature)
@@ -227,9 +253,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
use p384::ecdsa::{Signature, VerifyingKey};
use p384::elliptic_curve::sec1::FromEncodedPoint;
use p384::{AffinePoint, EncodedPoint};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for ES384".to_string())
})?;
let crv = jwk
.crv
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()))?;
if crv != "P-384" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for ES384: {}",
@@ -237,14 +264,18 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.decode(
jwk.x
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let y_bytes = URL_SAFE_NO_PAD
.decode(jwk.y.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
})?)
.decode(
jwk.y
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
let point = EncodedPoint::from_affine_coordinates(
x_bytes.as_slice().into(),
@@ -252,8 +283,8 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
false,
);
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 affine =
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
let verifying_key = VerifyingKey::from_affine(affine)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
let sig = Signature::from_slice(signature)
@@ -265,9 +296,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
use ed25519_dalek::{Signature, VerifyingKey};
let crv = jwk.crv.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string())
})?;
let crv = jwk
.crv
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()))?;
if crv != "Ed25519" {
return Err(OAuthError::InvalidDpopProof(format!(
"Invalid curve for EdDSA: {}",
@@ -275,13 +307,15 @@ fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
)));
}
let x_bytes = URL_SAFE_NO_PAD
.decode(jwk.x.as_ref().ok_or_else(|| {
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
})?)
.decode(
jwk.x
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| {
OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string())
})?;
let key_bytes: [u8; 32] = x_bytes
.try_into()
.map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?;
let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| {
@@ -308,10 +342,7 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
.y
.as_ref()
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?;
format!(
r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
crv, x, y
)
format!(r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, crv, x, y)
}
"OKP" => {
let crv = jwk
@@ -333,14 +364,14 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let hash = hasher.finalize();
Ok(URL_SAFE_NO_PAD.encode(&hash))
Ok(URL_SAFE_NO_PAD.encode(hash))
}
pub fn compute_access_token_hash(access_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(access_token.as_bytes());
let hash = hasher.finalize();
URL_SAFE_NO_PAD.encode(&hash)
URL_SAFE_NO_PAD.encode(hash)
}
#[cfg(test)]
+166 -96
View File
@@ -1,16 +1,21 @@
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
use crate::oauth::{
Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates,
};
use crate::state::{AppState, RateLimitKind};
use axum::{
Form, Json,
extract::{Query, State},
http::{HeaderMap, StatusCode, header::{SET_COOKIE, LOCATION}},
response::{IntoResponse, Redirect, Response, Html},
http::{
HeaderMap, StatusCode,
header::{LOCATION, SET_COOKIE},
},
response::{Html, IntoResponse, Redirect, Response},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use urlencoding::encode as url_encode;
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates};
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
@@ -34,18 +39,15 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
}
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"0.0.0.0".to_string()
}
@@ -59,8 +61,7 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
fn make_device_cookie(device_id: &str) -> String {
format!(
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
DEVICE_COOKIE_NAME,
device_id
DEVICE_COOKIE_NAME, device_id
)
}
@@ -127,7 +128,8 @@ pub async fn authorize_get(
"invalid_request",
Some("Missing request_uri parameter. Use PAR to initiate authorization."),
)),
).into_response();
)
.into_response();
}
};
let request_data = match db::get_authorization_request(&state.db, &request_uri).await {
@@ -146,9 +148,12 @@ pub async fn authorize_get(
axum::http::StatusCode::BAD_REQUEST,
Html(templates::error_page(
"invalid_request",
Some("Invalid or expired request_uri. Please start a new authorization request."),
Some(
"Invalid or expired request_uri. Please start a new authorization request.",
),
)),
).into_response();
)
.into_response();
}
Err(e) => {
if wants_json(&headers) {
@@ -158,7 +163,8 @@ pub async fn authorize_get(
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
).into_response();
)
.into_response();
}
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
@@ -166,7 +172,8 @@ pub async fn authorize_get(
"server_error",
Some(&format!("Database error: {:?}", e)),
)),
).into_response();
)
.into_response();
}
};
if request_data.expires_at < Utc::now() {
@@ -186,7 +193,8 @@ pub async fn authorize_get(
"invalid_request",
Some("Authorization request has expired. Please start a new request."),
)),
).into_response();
)
.into_response();
}
if wants_json(&headers) {
return Json(AuthorizeResponse {
@@ -196,13 +204,14 @@ pub async fn authorize_get(
redirect_uri: request_data.parameters.redirect_uri.clone(),
state: request_data.parameters.state.clone(),
login_hint: request_data.parameters.login_hint.clone(),
}).into_response();
})
.into_response();
}
let force_new_account = query.new_account.unwrap_or(false);
if !force_new_account {
if let Some(device_id) = extract_device_cookie(&headers) {
if let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await {
if !accounts.is_empty() {
if !force_new_account
&& let Some(device_id) = extract_device_cookie(&headers)
&& let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
&& !accounts.is_empty() {
let device_accounts: Vec<DeviceAccount> = accounts
.into_iter()
.map(|row| DeviceAccount {
@@ -217,11 +226,9 @@ pub async fn authorize_get(
None,
&request_uri,
&device_accounts,
)).into_response();
))
.into_response();
}
}
}
}
Html(templates::login_page(
&request_data.parameters.client_id,
None,
@@ -229,22 +236,25 @@ pub async fn authorize_get(
&request_uri,
None,
request_data.parameters.login_hint.as_deref(),
)).into_response()
))
.into_response()
}
pub async fn authorize_get_json(
State(state): State<AppState>,
Query(query): Query<AuthorizeQuery>,
) -> Result<Json<AuthorizeResponse>, OAuthError> {
let request_uri = query.request_uri.ok_or_else(|| {
OAuthError::InvalidRequest("request_uri is required".to_string())
})?;
let request_uri = query
.request_uri
.ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?;
let request_data = db::get_authorization_request(&state.db, &request_uri)
.await?
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
if request_data.expires_at < Utc::now() {
db::delete_authorization_request(&state.db, &request_uri).await?;
return Err(OAuthError::InvalidRequest("request_uri has expired".to_string()));
return Err(OAuthError::InvalidRequest(
"request_uri has expired".to_string(),
));
}
Ok(Json(AuthorizeResponse {
client_id: request_data.parameters.client_id.clone(),
@@ -263,7 +273,10 @@ pub async fn authorize_post(
) -> Response {
let json_response = wants_json(&headers);
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth authorize rate limit exceeded");
if json_response {
return (
@@ -272,7 +285,8 @@ pub async fn authorize_post(
"error": "RateLimitExceeded",
"error_description": "Too many login attempts. Please try again later."
})),
).into_response();
)
.into_response();
}
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
@@ -280,7 +294,8 @@ pub async fn authorize_post(
"RateLimitExceeded",
Some("Too many login attempts. Please try again later."),
)),
).into_response();
)
.into_response();
}
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
Ok(Some(data)) => data,
@@ -292,12 +307,14 @@ pub async fn authorize_post(
"error": "invalid_request",
"error_description": "Invalid or expired request_uri."
})),
).into_response();
)
.into_response();
}
return Html(templates::error_page(
"invalid_request",
Some("Invalid or expired request_uri. Please start a new authorization request."),
)).into_response();
))
.into_response();
}
Err(e) => {
if json_response {
@@ -307,12 +324,14 @@ pub async fn authorize_post(
"error": "server_error",
"error_description": format!("Database error: {:?}", e)
})),
).into_response();
)
.into_response();
}
return Html(templates::error_page(
"server_error",
Some(&format!("Database error: {:?}", e)),
)).into_response();
))
.into_response();
}
};
if request_data.expires_at < Utc::now() {
@@ -324,12 +343,14 @@ pub async fn authorize_post(
"error": "invalid_request",
"error_description": "Authorization request has expired."
})),
).into_response();
)
.into_response();
}
return Html(templates::error_page(
"invalid_request",
Some("Authorization request has expired. Please start a new request."),
)).into_response();
))
.into_response();
}
let show_login_error = |error_msg: &str, json: bool| -> Response {
if json {
@@ -339,7 +360,8 @@ pub async fn authorize_post(
"error": "access_denied",
"error_description": error_msg
})),
).into_response();
)
.into_response();
}
Html(templates::login_page(
&request_data.parameters.client_id,
@@ -348,12 +370,17 @@ pub async fn authorize_post(
&form.request_uri,
Some(error_msg),
Some(&form.username),
)).into_response()
))
.into_response()
};
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let normalized_username = form.username.trim();
let normalized_username = normalized_username.strip_prefix('@').unwrap_or(normalized_username);
let normalized_username = if let Some(bare_handle) = normalized_username.strip_suffix(&format!(".{}", pds_hostname)) {
let normalized_username = normalized_username
.strip_prefix('@')
.unwrap_or(normalized_username);
let normalized_username = if let Some(bare_handle) =
normalized_username.strip_suffix(&format!(".{}", pds_hostname))
{
bare_handle.to_string()
} else {
normalized_username.to_string()
@@ -401,13 +428,11 @@ pub async fn authorize_post(
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await {
Ok(challenge) => {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = enqueue_2fa_code(
&state.db,
user.id,
&challenge.code,
&hostname,
).await {
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await
{
tracing::warn!(
did = %user.did,
error = %e,
@@ -441,7 +466,10 @@ pub async fn authorize_post(
ip_address: extract_client_ip(&headers),
last_seen_at: Utc::now(),
};
if db::create_device(&state.db, &new_id.0, &device_data).await.is_ok() {
if db::create_device(&state.db, &new_id.0, &device_data)
.await
.is_ok()
{
new_cookie = Some(make_device_cookie(&new_id.0));
device_id = Some(new_id.0.clone());
}
@@ -449,7 +477,7 @@ pub async fn authorize_post(
};
let _ = db::upsert_account_device(&state.db, &user.did, &final_device_id).await;
}
if let Err(_) = db::update_authorization_request(
if db::update_authorization_request(
&state.db,
&form.request_uri,
&user.did,
@@ -457,6 +485,7 @@ pub async fn authorize_post(
&code.0,
)
.await
.is_err()
{
return show_login_error("An error occurred. Please try again.", json_response);
}
@@ -466,7 +495,11 @@ pub async fn authorize_post(
request_data.parameters.state.as_deref(),
);
if let Some(cookie) = new_cookie {
(StatusCode::SEE_OTHER, [(SET_COOKIE, cookie), (LOCATION, redirect_url)]).into_response()
(
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
)
.into_response()
} else {
redirect_see_other(&redirect_url)
}
@@ -483,13 +516,15 @@ pub async fn authorize_select(
return Html(templates::error_page(
"invalid_request",
Some("Invalid or expired request_uri. Please start a new authorization request."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
if request_data.expires_at < Utc::now() {
@@ -497,7 +532,8 @@ pub async fn authorize_select(
return Html(templates::error_page(
"invalid_request",
Some("Authorization request has expired. Please start a new request."),
)).into_response();
))
.into_response();
}
let device_id = match extract_device_cookie(&headers) {
Some(id) => id,
@@ -505,7 +541,8 @@ pub async fn authorize_select(
return Html(templates::error_page(
"invalid_request",
Some("No device session found. Please sign in."),
)).into_response();
))
.into_response();
}
};
let account_valid = match db::verify_account_on_device(&state.db, &device_id, &form.did).await {
@@ -514,14 +551,16 @@ pub async fn authorize_select(
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
if !account_valid {
return Html(templates::error_page(
"access_denied",
Some("This account is not available on this device. Please sign in."),
)).into_response();
))
.into_response();
}
let user = match sqlx::query!(
r#"
@@ -553,13 +592,11 @@ pub async fn authorize_select(
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await {
Ok(challenge) => {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = enqueue_2fa_code(
&state.db,
user.id,
&challenge.code,
&hostname,
).await {
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) =
enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await
{
tracing::warn!(
did = %form.did,
error = %e,
@@ -578,13 +615,14 @@ pub async fn authorize_select(
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
}
}
let _ = db::upsert_account_device(&state.db, &form.did, &device_id).await;
let code = Code::generate();
if let Err(_) = db::update_authorization_request(
if db::update_authorization_request(
&state.db,
&form.request_uri,
&form.did,
@@ -592,11 +630,13 @@ pub async fn authorize_select(
&code.0,
)
.await
.is_err()
{
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
@@ -615,7 +655,10 @@ fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname))));
redirect_url.push_str(&format!(
"&iss={}",
url_encode(&format!("https://{}", pds_hostname))
));
redirect_url
}
@@ -674,13 +717,15 @@ pub async fn authorize_2fa_get(
return Html(templates::error_page(
"invalid_request",
Some("No 2FA challenge found. Please start over."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
if challenge.expires_at < Utc::now() {
@@ -688,7 +733,8 @@ pub async fn authorize_2fa_get(
return Html(templates::error_page(
"invalid_request",
Some("2FA code has expired. Please start over."),
)).into_response();
))
.into_response();
}
let _request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
Ok(Some(d)) => d,
@@ -696,13 +742,15 @@ pub async fn authorize_2fa_get(
return Html(templates::error_page(
"invalid_request",
Some("Authorization request not found. Please start over."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
let channel = query.channel.as_deref().unwrap_or("email");
@@ -710,7 +758,8 @@ pub async fn authorize_2fa_get(
&query.request_uri,
channel,
None,
)).into_response()
))
.into_response()
}
pub async fn authorize_2fa_post(
@@ -719,7 +768,10 @@ pub async fn authorize_2fa_post(
Form(form): Form<Authorize2faSubmit>,
) -> Response {
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded");
return (
axum::http::StatusCode::TOO_MANY_REQUESTS,
@@ -727,7 +779,8 @@ pub async fn authorize_2fa_post(
"RateLimitExceeded",
Some("Too many attempts. Please try again later."),
)),
).into_response();
)
.into_response();
}
let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await {
Ok(Some(c)) => c,
@@ -735,13 +788,15 @@ pub async fn authorize_2fa_post(
return Html(templates::error_page(
"invalid_request",
Some("No 2FA challenge found. Please start over."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
if challenge.expires_at < Utc::now() {
@@ -749,16 +804,23 @@ pub async fn authorize_2fa_post(
return Html(templates::error_page(
"invalid_request",
Some("2FA code has expired. Please start over."),
)).into_response();
))
.into_response();
}
if challenge.attempts >= MAX_2FA_ATTEMPTS {
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
return Html(templates::error_page(
"access_denied",
Some("Too many failed attempts. Please start over."),
)).into_response();
))
.into_response();
}
let code_valid: bool = form.code.trim().as_bytes().ct_eq(challenge.code.as_bytes()).into();
let code_valid: bool = form
.code
.trim()
.as_bytes()
.ct_eq(challenge.code.as_bytes())
.into();
if !code_valid {
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
let channel = match sqlx::query_scalar!(
@@ -771,26 +833,30 @@ pub async fn authorize_2fa_post(
Ok(Some(ch)) => channel_display_name(ch).to_string(),
Ok(None) | Err(_) => "email".to_string(),
};
let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await
{
Ok(Some(d)) => d,
Ok(None) => {
return Html(templates::error_page(
"invalid_request",
Some("Authorization request not found. Please start over."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
};
return Html(templates::two_factor_page(
&form.request_uri,
&channel,
Some("Invalid verification code. Please try again."),
)).into_response();
))
.into_response();
}
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
@@ -799,18 +865,20 @@ pub async fn authorize_2fa_post(
return Html(templates::error_page(
"invalid_request",
Some("Authorization request not found."),
)).into_response();
))
.into_response();
}
Err(_) => {
return Html(templates::error_page(
"server_error",
Some("An error occurred."),
)).into_response();
))
.into_response();
}
};
let code = Code::generate();
let device_id = extract_device_cookie(&headers);
if let Err(_) = db::update_authorization_request(
if db::update_authorization_request(
&state.db,
&form.request_uri,
&challenge.did,
@@ -818,11 +886,13 @@ pub async fn authorize_2fa_post(
&code.0,
)
.await
.is_err()
{
return Html(templates::error_page(
"server_error",
Some("An error occurred. Please try again."),
)).into_response();
))
.into_response();
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::oauth::jwks::{JwkSet, create_jwk_set};
use crate::state::AppState;
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use crate::state::AppState;
use crate::oauth::jwks::{JwkSet, create_jwk_set};
#[derive(Debug, Serialize, Deserialize)]
pub struct ProtectedResourceMetadata {
+2 -2
View File
@@ -1,9 +1,9 @@
pub mod authorize;
pub mod metadata;
pub mod par;
pub mod authorize;
pub mod token;
pub use authorize::*;
pub use metadata::*;
pub use par::*;
pub use authorize::*;
pub use token::*;
+12 -12
View File
@@ -1,16 +1,11 @@
use axum::{
Form, Json,
extract::State,
http::HeaderMap,
};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{
AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId,
client::ClientMetadataCache,
db,
client::ClientMetadataCache, db,
};
use crate::state::{AppState, RateLimitKind};
use axum::{Form, Json, extract::State, http::HeaderMap};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
const PAR_EXPIRY_SECONDS: i64 = 600;
const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"];
@@ -52,7 +47,10 @@ pub async fn pushed_authorization_request(
Form(request): Form<ParRequest>,
) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::OAuthPar, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthPar, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded");
return Err(OAuthError::RateLimited);
}
@@ -61,7 +59,9 @@ pub async fn pushed_authorization_request(
"response_type must be 'code'".to_string(),
));
}
let code_challenge = request.code_challenge.as_ref()
let code_challenge = request
.code_challenge
.as_ref()
.filter(|s| !s.is_empty())
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or("");
+32 -32
View File
@@ -1,16 +1,16 @@
use axum::http::HeaderMap;
use axum::Json;
use chrono::{Duration, Utc};
use super::helpers::{create_access_token, verify_pkce};
use super::types::{TokenRequest, TokenResponse};
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};
use crate::state::AppState;
use axum::Json;
use axum::http::HeaderMap;
use chrono::{Duration, Utc};
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60;
@@ -31,19 +31,22 @@ pub async fn handle_authorization_code_grant(
.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()));
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 {
if let Some(request_client_id) = &request.client_id
&& request_client_id != &auth_request.client_id {
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
}
}
let did = auth_request
.did
.ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
let client_metadata_cache = ClientMetadataCache::new(3600);
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
let client_auth = if let (Some(assertion), Some(assertion_type)) = (&request.client_assertion, &request.client_assertion_type) {
let client_auth = if let (Some(assertion), Some(assertion_type)) =
(&request.client_assertion, &request.client_assertion_type)
{
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
return Err(OAuthError::InvalidClient(
"Unsupported client_assertion_type".to_string(),
@@ -61,15 +64,17 @@ pub async fn handle_authorization_code_grant(
};
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
if let Some(redirect_uri) = &request.redirect_uri {
if redirect_uri != &auth_request.parameters.redirect_uri {
return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string()));
if let Some(redirect_uri) = &request.redirect_uri
&& 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 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? {
@@ -77,13 +82,12 @@ pub async fn handle_authorization_code_grant(
"DPoP proof has already been used".to_string(),
));
}
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt {
if &result.jkt != expected_jkt {
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt
&& &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(
@@ -124,10 +128,7 @@ pub async fn handle_authorization_code_grant(
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(),
);
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
Ok((
response_headers,
Json(TokenResponse {
@@ -161,12 +162,15 @@ pub async fn handle_refresh_token_grant(
.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()));
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 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? {
@@ -174,13 +178,12 @@ pub async fn handle_refresh_token_grant(
"DPoP proof has already been used".to_string(),
));
}
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
if &result.jkt != expected_jkt {
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt
&& &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(
@@ -204,10 +207,7 @@ pub async fn handle_refresh_token_grant(
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(),
);
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
Ok((
response_headers,
Json(TokenResponse {
+21 -9
View File
@@ -1,11 +1,11 @@
use crate::config::AuthConfig;
use crate::oauth::OAuthError;
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;
@@ -19,9 +19,15 @@ pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAut
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()));
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(())
}
@@ -61,7 +67,7 @@ pub fn create_access_token(
.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);
let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
Ok(format!("{}.{}", signing_input, signature_b64))
}
@@ -76,10 +82,14 @@ pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
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()));
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()));
return Err(OAuthError::InvalidToken(
"Unsupported algorithm".to_string(),
));
}
let config = AuthConfig::get();
let secret = config.jwt_secret();
@@ -93,7 +103,9 @@ pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
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()));
return Err(OAuthError::InvalidToken(
"Invalid token signature".to_string(),
));
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
+12 -6
View File
@@ -1,11 +1,11 @@
use axum::{Form, Json};
use super::helpers::extract_token_claims;
use crate::oauth::{OAuthError, db};
use crate::state::{AppState, RateLimitKind};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::{Form, Json};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::state::{AppState, RateLimitKind};
use crate::oauth::{OAuthError, db};
use super::helpers::extract_token_claims;
#[derive(Debug, Deserialize)]
pub struct RevokeRequest {
@@ -20,7 +20,10 @@ pub async fn revoke_token(
Form(request): Form<RevokeRequest>,
) -> Result<StatusCode, OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded");
return Err(OAuthError::RateLimited);
}
@@ -74,7 +77,10 @@ pub async fn introspect_token(
Form(request): Form<IntrospectRequest>,
) -> Result<Json<IntrospectResponse>, OAuthError> {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded");
return Err(OAuthError::RateLimited);
}
+14 -20
View File
@@ -3,34 +3,27 @@ mod helpers;
mod introspect;
mod types;
use axum::{
Form, Json,
extract::State,
http::HeaderMap,
};
use crate::state::{AppState, RateLimitKind};
use crate::oauth::OAuthError;
use crate::state::{AppState, RateLimitKind};
use axum::{Form, Json, extract::State, http::HeaderMap};
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 helpers::{TokenClaims, create_access_token, extract_token_claims, verify_pkce};
pub use introspect::{
introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest,
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
};
pub use types::{TokenRequest, TokenResponse};
fn extract_client_ip(headers: &HeaderMap) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
"unknown".to_string()
}
@@ -40,7 +33,10 @@ pub async fn token_endpoint(
Form(request): Form<TokenRequest>,
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
let client_ip = extract_client_ip(&headers);
if !state.check_rate_limit(RateLimitKind::OAuthToken, &client_ip).await {
if !state
.check_rate_limit(RateLimitKind::OAuthToken, &client_ip)
.await
{
tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded");
return Err(OAuthError::InvalidRequest(
"Too many requests. Please try again later.".to_string(),
@@ -54,9 +50,7 @@ pub async fn token_endpoint(
"authorization_code" => {
handle_authorization_code_grant(state, headers, request, dpop_proof).await
}
"refresh_token" => {
handle_refresh_token_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
+10 -18
View File
@@ -37,21 +37,15 @@ impl IntoResponse for OAuthError {
OAuthError::InvalidClient(msg) => {
(StatusCode::UNAUTHORIZED, "invalid_client", Some(msg))
}
OAuthError::InvalidGrant(msg) => {
(StatusCode::BAD_REQUEST, "invalid_grant", Some(msg))
}
OAuthError::InvalidGrant(msg) => (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)),
OAuthError::UnauthorizedClient(msg) => {
(StatusCode::UNAUTHORIZED, "unauthorized_client", Some(msg))
}
OAuthError::UnsupportedGrantType(msg) => {
(StatusCode::BAD_REQUEST, "unsupported_grant_type", Some(msg))
}
OAuthError::InvalidScope(msg) => {
(StatusCode::BAD_REQUEST, "invalid_scope", Some(msg))
}
OAuthError::AccessDenied(msg) => {
(StatusCode::FORBIDDEN, "access_denied", Some(msg))
}
OAuthError::InvalidScope(msg) => (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)),
OAuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, "access_denied", Some(msg)),
OAuthError::ServerError(msg) => {
(StatusCode::INTERNAL_SERVER_ERROR, "server_error", Some(msg))
}
@@ -69,15 +63,13 @@ impl IntoResponse for OAuthError {
OAuthError::InvalidDpopProof(msg) => {
(StatusCode::UNAUTHORIZED, "invalid_dpop_proof", Some(msg))
}
OAuthError::ExpiredToken(msg) => {
(StatusCode::UNAUTHORIZED, "invalid_token", Some(msg))
}
OAuthError::InvalidToken(msg) => {
(StatusCode::UNAUTHORIZED, "invalid_token", Some(msg))
}
OAuthError::RateLimited => {
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", Some("Too many requests. Please try again later.".to_string()))
}
OAuthError::ExpiredToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)),
OAuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)),
OAuthError::RateLimited => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
Some("Too many requests. Please try again later.".to_string()),
),
};
(
status,
+7 -5
View File
@@ -1,14 +1,16 @@
pub mod types;
pub mod client;
pub mod db;
pub mod dpop;
pub mod jwks;
pub mod client;
pub mod endpoints;
pub mod error;
pub mod jwks;
pub mod templates;
pub mod types;
pub mod verify;
pub use types::*;
pub use error::OAuthError;
pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError};
pub use templates::{DeviceAccount, mask_email};
pub use types::*;
pub use verify::{
OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
};
+21 -10
View File
@@ -487,18 +487,23 @@ pub fn account_selector_page(
)
}
pub fn two_factor_page(
request_uri: &str,
channel: &str,
error_message: Option<&str>,
) -> String {
pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String {
let error_html = error_message
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
.unwrap_or_default();
let (title, subtitle) = match channel {
"email" => ("Check your email", "We sent a verification code to your email"),
"Discord" => ("Check Discord", "We sent a verification code to your Discord"),
"Telegram" => ("Check Telegram", "We sent a verification code to your Telegram"),
"email" => (
"Check your email",
"We sent a verification code to your email",
),
"Discord" => (
"Check Discord",
"We sent a verification code to your Discord",
),
"Telegram" => (
"Check Telegram",
"We sent a verification code to your Telegram",
),
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
_ => ("Check your messages", "We sent you a verification code"),
};
@@ -546,7 +551,8 @@ pub fn two_factor_page(
}
pub fn error_page(error: &str, error_description: Option<&str>) -> String {
let description = error_description.unwrap_or("An error occurred during the authorization process.");
let description =
error_description.unwrap_or("An error occurred during the authorization process.");
format!(
r#"<!DOCTYPE html>
<html lang="en">
@@ -618,7 +624,12 @@ fn get_initials(handle: &str) -> String {
if clean.is_empty() {
return "?".to_string();
}
clean.chars().next().unwrap_or('?').to_uppercase().to_string()
clean
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string()
}
pub fn mask_email(email: &str) -> String {
+4 -1
View File
@@ -22,7 +22,10 @@ pub struct RefreshToken(pub String);
impl RequestId {
pub fn generate() -> Self {
Self(format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()))
Self(format!(
"urn:ietf:params:oauth:request_uri:{}",
uuid::Uuid::new_v4()
))
}
}
+43 -26
View File
@@ -1,8 +1,8 @@
use axum::{
Json,
extract::FromRequestParts,
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
Json,
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, Mac};
@@ -11,11 +11,11 @@ use sha2::Sha256;
use sqlx::PgPool;
use subtle::ConstantTimeEq;
use crate::config::AuthConfig;
use crate::state::AppState;
use super::OAuthError;
use super::db;
use super::dpop::DPoPVerifier;
use super::OAuthError;
use crate::config::AuthConfig;
use crate::state::AppState;
pub struct OAuthTokenInfo {
pub did: String,
@@ -48,13 +48,13 @@ pub async fn verify_oauth_access_token(
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
}
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
let proof = dpop_proof.ok_or_else(|| {
OAuthError::UseDpopNonce("DPoP proof required".to_string())
})?;
let proof = dpop_proof
.ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?;
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let access_token_hash = compute_ath(access_token);
let result = verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
let result =
verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
return Err(OAuthError::InvalidDpopProof(
"DPoP proof has already been used".to_string(),
@@ -85,10 +85,14 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
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()));
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()));
return Err(OAuthError::InvalidToken(
"Unsupported algorithm".to_string(),
));
}
let config = AuthConfig::get();
let secret = config.jwt_secret();
@@ -102,7 +106,9 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
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()));
return Err(OAuthError::InvalidToken(
"Invalid token signature".to_string(),
));
}
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
@@ -127,7 +133,10 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
.and_then(|s| s.as_str())
.ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))?
.to_string();
let scope = payload.get("scope").and_then(|s| s.as_str()).map(|s| s.to_string());
let scope = payload
.get("scope")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let dpop_jkt = payload
.get("cnf")
.and_then(|c| c.get("jkt"))
@@ -152,7 +161,7 @@ fn compute_ath(access_token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(access_token.as_bytes());
let hash = hasher.finalize();
URL_SAFE_NO_PAD.encode(&hash)
URL_SAFE_NO_PAD.encode(hash)
}
pub fn generate_dpop_nonce() -> String {
@@ -186,10 +195,9 @@ impl IntoResponse for OAuthAuthError {
)
.into_response();
if let Some(nonce) = self.dpop_nonce {
response.headers_mut().insert(
"DPoP-Nonce",
nonce.parse().unwrap(),
);
response
.headers_mut()
.insert("DPoP-Nonce", nonce.parse().unwrap());
}
response
}
@@ -198,7 +206,10 @@ impl IntoResponse for OAuthAuthError {
impl FromRequestParts<AppState> for OAuthUser {
type Rejection = OAuthAuthError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
@@ -210,9 +221,13 @@ impl FromRequestParts<AppState> for OAuthUser {
dpop_nonce: None,
})?;
let auth_header_trimmed = auth_header.trim();
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") {
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
&& auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ")
{
(auth_header_trimmed[7..].trim(), false)
} else if auth_header_trimmed.len() >= 5 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") {
} else if auth_header_trimmed.len() >= 5
&& auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ")
{
(auth_header_trimmed[5..].trim(), true)
} else {
return Err(OAuthAuthError {
@@ -222,10 +237,7 @@ impl FromRequestParts<AppState> for OAuthUser {
dpop_nonce: None,
});
};
let dpop_proof = parts
.headers
.get("DPoP")
.and_then(|v| v.to_str().ok());
let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
if let Ok(result) = try_legacy_auth(&state.db, token).await {
return Ok(OAuthUser {
did: result.did,
@@ -236,7 +248,8 @@ impl FromRequestParts<AppState> for OAuthUser {
}
let http_method = parts.method.as_str();
let http_uri = parts.uri.to_string();
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await {
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
{
Ok(result) => Ok(OAuthUser {
did: result.did,
client_id: Some(result.client_id),
@@ -259,7 +272,11 @@ impl FromRequestParts<AppState> for OAuthUser {
})
}
Err(e) => {
let nonce = if is_dpop_token { Some(generate_dpop_nonce()) } else { None };
let nonce = if is_dpop_token {
Some(generate_dpop_nonce())
} else {
None
};
Err(OAuthAuthError {
status: StatusCode::UNAUTHORIZED,
error: "AuthenticationFailed".to_string(),
+96 -67
View File
@@ -1,9 +1,9 @@
use base32::Alphabet;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use k256::ecdsa::{SigningKey, Signature, signature::Signer};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::time::Duration;
@@ -102,10 +102,7 @@ impl PlcClient {
.pool_max_idle_per_host(5)
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url,
client,
}
Self { base_url, client }
}
fn encode_did(did: &str) -> String {
@@ -126,7 +123,10 @@ impl PlcClient {
status, body
)));
}
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
}
pub async fn get_document_data(&self, did: &str) -> Result<Value, PlcError> {
@@ -143,7 +143,10 @@ impl PlcClient {
status, body
)));
}
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
}
pub async fn get_last_op(&self, did: &str) -> Result<PlcOpOrTombstone, PlcError> {
@@ -160,7 +163,10 @@ impl PlcClient {
status, body
)));
}
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
}
pub async fn get_audit_log(&self, did: &str) -> Result<Vec<Value>, PlcError> {
@@ -177,16 +183,15 @@ impl PlcClient {
status, body
)));
}
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
}
pub async fn send_operation(&self, did: &str, operation: &Value) -> Result<(), PlcError> {
let url = format!("{}/{}", self.base_url, Self::encode_did(did));
let response = self.client
.post(&url)
.json(operation)
.send()
.await?;
let response = self.client.post(&url).json(operation).send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
@@ -200,8 +205,8 @@ impl PlcClient {
}
pub fn cid_for_cbor(value: &Value) -> Result<String, PlcError> {
let cbor_bytes = serde_ipld_dagcbor::to_vec(value)
.map_err(|e| PlcError::Serialization(e.to_string()))?;
let cbor_bytes =
serde_ipld_dagcbor::to_vec(value).map_err(|e| PlcError::Serialization(e.to_string()))?;
let mut hasher = Sha256::new();
hasher.update(&cbor_bytes);
let hash = hasher.finalize();
@@ -211,16 +216,13 @@ pub fn cid_for_cbor(value: &Value) -> Result<String, PlcError> {
Ok(cid.to_string())
}
pub fn sign_operation(
operation: &Value,
signing_key: &SigningKey,
) -> Result<Value, PlcError> {
pub fn sign_operation(operation: &Value, signing_key: &SigningKey) -> Result<Value, PlcError> {
let mut op = operation.clone();
if let Some(obj) = op.as_object_mut() {
obj.remove("sig");
}
let cbor_bytes = serde_ipld_dagcbor::to_vec(&op)
.map_err(|e| PlcError::Serialization(e.to_string()))?;
let cbor_bytes =
serde_ipld_dagcbor::to_vec(&op).map_err(|e| PlcError::Serialization(e.to_string()))?;
let signature: Signature = signing_key.sign(&cbor_bytes);
let sig_bytes = signature.to_bytes();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes);
@@ -238,10 +240,12 @@ pub fn create_update_op(
services: Option<HashMap<String, PlcService>>,
) -> Result<Value, PlcError> {
let prev_value = match last_op {
PlcOpOrTombstone::Operation(op) => serde_json::to_value(op)
.map_err(|e| PlcError::Serialization(e.to_string()))?,
PlcOpOrTombstone::Tombstone(t) => serde_json::to_value(t)
.map_err(|e| PlcError::Serialization(e.to_string()))?,
PlcOpOrTombstone::Operation(op) => {
serde_json::to_value(op).map_err(|e| PlcError::Serialization(e.to_string()))?
}
PlcOpOrTombstone::Tombstone(t) => {
serde_json::to_value(t).map_err(|e| PlcError::Serialization(e.to_string()))?
}
};
let prev_cid = cid_for_cbor(&prev_value)?;
let (base_rotation_keys, base_verification_methods, base_also_known_as, base_services) =
@@ -309,8 +313,8 @@ pub fn create_genesis_operation(
prev: None,
sig: None,
};
let genesis_value = serde_json::to_value(&genesis_op)
.map_err(|e| PlcError::Serialization(e.to_string()))?;
let genesis_value =
serde_json::to_value(&genesis_op).map_err(|e| PlcError::Serialization(e.to_string()))?;
let signed_op = sign_operation(&genesis_value, signing_key)?;
let did = did_for_genesis_op(&signed_op)?;
Ok(GenesisResult {
@@ -331,20 +335,29 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result<String, PlcError> {
}
pub fn validate_plc_operation(op: &Value) -> Result<(), PlcError> {
let obj = op.as_object()
let obj = op
.as_object()
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
let op_type = obj.get("type")
let op_type = obj
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| PlcError::InvalidResponse("Missing type field".to_string()))?;
if op_type != "plc_operation" && op_type != "plc_tombstone" {
return Err(PlcError::InvalidResponse(format!("Invalid type: {}", op_type)));
return Err(PlcError::InvalidResponse(format!(
"Invalid type: {}",
op_type
)));
}
if op_type == "plc_operation" {
if obj.get("rotationKeys").is_none() {
return Err(PlcError::InvalidResponse("Missing rotationKeys".to_string()));
return Err(PlcError::InvalidResponse(
"Missing rotationKeys".to_string(),
));
}
if obj.get("verificationMethods").is_none() {
return Err(PlcError::InvalidResponse("Missing verificationMethods".to_string()));
return Err(PlcError::InvalidResponse(
"Missing verificationMethods".to_string(),
));
}
if obj.get("alsoKnownAs").is_none() {
return Err(PlcError::InvalidResponse("Missing alsoKnownAs".to_string()));
@@ -371,35 +384,37 @@ pub fn validate_plc_operation_for_submission(
ctx: &PlcValidationContext,
) -> Result<(), PlcError> {
validate_plc_operation(op)?;
let obj = op.as_object()
let obj = op
.as_object()
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
let op_type = obj.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
let op_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if op_type != "plc_operation" {
return Ok(());
}
let rotation_keys = obj.get("rotationKeys")
let rotation_keys = obj
.get("rotationKeys")
.and_then(|v| v.as_array())
.ok_or_else(|| PlcError::InvalidResponse("rotationKeys must be an array".to_string()))?;
let rotation_key_strings: Vec<&str> = rotation_keys
.iter()
.filter_map(|v| v.as_str())
.collect();
let rotation_key_strings: Vec<&str> = rotation_keys.iter().filter_map(|v| v.as_str()).collect();
if !rotation_key_strings.contains(&ctx.server_rotation_key.as_str()) {
return Err(PlcError::InvalidResponse(
"Rotation keys do not include server's rotation key".to_string()
"Rotation keys do not include server's rotation key".to_string(),
));
}
let verification_methods = obj.get("verificationMethods")
let verification_methods = obj
.get("verificationMethods")
.and_then(|v| v.as_object())
.ok_or_else(|| PlcError::InvalidResponse("verificationMethods must be an object".to_string()))?;
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) {
if atproto_key != ctx.expected_signing_key {
return Err(PlcError::InvalidResponse("Incorrect signing key".to_string()));
.ok_or_else(|| {
PlcError::InvalidResponse("verificationMethods must be an object".to_string())
})?;
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
&& atproto_key != ctx.expected_signing_key {
return Err(PlcError::InvalidResponse(
"Incorrect signing key".to_string(),
));
}
}
let also_known_as = obj.get("alsoKnownAs")
let also_known_as = obj
.get("alsoKnownAs")
.and_then(|v| v.as_array())
.ok_or_else(|| PlcError::InvalidResponse("alsoKnownAs must be an array".to_string()))?;
let expected_handle_uri = format!("at://{}", ctx.expected_handle);
@@ -409,36 +424,42 @@ pub fn validate_plc_operation_for_submission(
.any(|s| s == expected_handle_uri);
if !has_correct_handle && !also_known_as.is_empty() {
return Err(PlcError::InvalidResponse(
"Incorrect handle in alsoKnownAs".to_string()
"Incorrect handle in alsoKnownAs".to_string(),
));
}
let services = obj.get("services")
let services = obj
.get("services")
.and_then(|v| v.as_object())
.ok_or_else(|| PlcError::InvalidResponse("services must be an object".to_string()))?;
if let Some(pds_service) = services.get("atproto_pds").and_then(|v| v.as_object()) {
let service_type = pds_service.get("type").and_then(|v| v.as_str()).unwrap_or("");
let service_type = pds_service
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if service_type != "AtprotoPersonalDataServer" {
return Err(PlcError::InvalidResponse(
"Incorrect type on atproto_pds service".to_string()
"Incorrect type on atproto_pds service".to_string(),
));
}
let endpoint = pds_service.get("endpoint").and_then(|v| v.as_str()).unwrap_or("");
let endpoint = pds_service
.get("endpoint")
.and_then(|v| v.as_str())
.unwrap_or("");
if endpoint != ctx.expected_pds_endpoint {
return Err(PlcError::InvalidResponse(
"Incorrect endpoint on atproto_pds service".to_string()
"Incorrect endpoint on atproto_pds service".to_string(),
));
}
}
Ok(())
}
pub fn verify_operation_signature(
op: &Value,
rotation_keys: &[String],
) -> Result<bool, PlcError> {
let obj = op.as_object()
pub fn verify_operation_signature(op: &Value, rotation_keys: &[String]) -> Result<bool, PlcError> {
let obj = op
.as_object()
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
let sig_b64 = obj.get("sig")
let sig_b64 = obj
.get("sig")
.and_then(|v| v.as_str())
.ok_or_else(|| PlcError::InvalidResponse("Missing sig".to_string()))?;
let sig_bytes = URL_SAFE_NO_PAD
@@ -467,21 +488,29 @@ fn verify_signature_with_did_key(
) -> Result<bool, PlcError> {
use k256::ecdsa::{VerifyingKey, signature::Verifier};
if !did_key.starts_with("did:key:z") {
return Err(PlcError::InvalidResponse("Invalid did:key format".to_string()));
return Err(PlcError::InvalidResponse(
"Invalid did:key format".to_string(),
));
}
let multibase_part = &did_key[8..];
let (_, decoded) = multibase::decode(multibase_part)
.map_err(|e| PlcError::InvalidResponse(format!("Failed to decode did:key: {}", e)))?;
if decoded.len() < 2 {
return Err(PlcError::InvalidResponse("Invalid did:key data".to_string()));
return Err(PlcError::InvalidResponse(
"Invalid did:key data".to_string(),
));
}
let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 {
(0xe701u16, &decoded[2..])
} else {
return Err(PlcError::InvalidResponse("Unsupported key type in did:key".to_string()));
return Err(PlcError::InvalidResponse(
"Unsupported key type in did:key".to_string(),
));
};
if codec != 0xe701 {
return Err(PlcError::InvalidResponse("Only secp256k1 keys are supported".to_string()));
return Err(PlcError::InvalidResponse(
"Only secp256k1 keys are supported".to_string(),
));
}
let verifying_key = VerifyingKey::from_sec1_bytes(key_bytes)
.map_err(|e| PlcError::InvalidResponse(format!("Invalid public key: {}", e)))?;
+60 -66
View File
@@ -1,21 +1,17 @@
use axum::{
Json,
body::Body,
extract::ConnectInfo,
http::{HeaderMap, Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use governor::{
Quota, RateLimiter,
clock::DefaultClock,
state::{InMemoryState, NotKeyed, keyed::DefaultKeyedStateStore},
};
use std::{
net::SocketAddr,
num::NonZeroU32,
sync::Arc,
};
use std::{net::SocketAddr, num::NonZeroU32, sync::Arc};
pub type KeyedRateLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
pub type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
@@ -44,101 +40,99 @@ impl Default for RateLimiters {
impl RateLimiters {
pub fn new() -> Self {
Self {
login: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(10).unwrap())
)),
oauth_token: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(30).unwrap())
)),
oauth_authorize: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(10).unwrap())
)),
password_reset: Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(5).unwrap())
)),
account_creation: Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(10).unwrap())
)),
refresh_session: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(60).unwrap())
)),
reset_password: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(10).unwrap())
)),
oauth_par: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(30).unwrap())
)),
oauth_introspect: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(30).unwrap())
)),
app_password: Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(10).unwrap())
)),
email_update: Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(5).unwrap())
)),
login: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
))),
oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
password_reset: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
))),
account_creation: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(10).unwrap(),
))),
refresh_session: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(60).unwrap(),
))),
reset_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
oauth_par: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
))),
oauth_introspect: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(30).unwrap(),
))),
app_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(5).unwrap(),
))),
}
}
pub fn with_login_limit(mut self, per_minute: u32) -> Self {
self.login = Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()))
));
self.login = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
)));
self
}
pub fn with_oauth_token_limit(mut self, per_minute: u32) -> Self {
self.oauth_token = Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()))
));
self.oauth_token = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()),
)));
self
}
pub fn with_oauth_authorize_limit(mut self, per_minute: u32) -> Self {
self.oauth_authorize = Arc::new(RateLimiter::keyed(
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()))
));
self.oauth_authorize = Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
)));
self
}
pub fn with_password_reset_limit(mut self, per_hour: u32) -> Self {
self.password_reset = Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()))
));
self.password_reset = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
)));
self
}
pub fn with_account_creation_limit(mut self, per_hour: u32) -> Self {
self.account_creation = Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()))
));
self.account_creation = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()),
)));
self
}
pub fn with_email_update_limit(mut self, per_hour: u32) -> Self {
self.email_update = Arc::new(RateLimiter::keyed(
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()))
));
self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour(
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
)));
self
}
}
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
if let Some(forwarded) = headers.get("x-forwarded-for") {
if let Ok(value) = forwarded.to_str() {
if let Some(first_ip) = value.split(',').next() {
if let Some(forwarded) = headers.get("x-forwarded-for")
&& let Ok(value) = forwarded.to_str()
&& let Some(first_ip) = value.split(',').next() {
return first_ip.trim().to_string();
}
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(value) = real_ip.to_str() {
if let Some(real_ip) = headers.get("x-real-ip")
&& let Ok(value) = real_ip.to_str() {
return value.trim().to_string();
}
}
addr.map(|a| a.ip().to_string()).unwrap_or_else(|| "unknown".to_string())
addr.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn rate_limit_response() -> Response {
+18 -10
View File
@@ -27,7 +27,7 @@ impl BlockStore for PostgresBlockStore {
let row = sqlx::query!("SELECT data FROM blocks WHERE cid = $1", &cid_bytes)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
.map_err(RepoError::storage)?;
match row {
Some(row) => Ok(Some(Bytes::from(row.data))),
None => Ok(None),
@@ -39,14 +39,22 @@ impl BlockStore for PostgresBlockStore {
let mut hasher = Sha256::new();
hasher.update(data);
let hash = hasher.finalize();
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 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();
sqlx::query!("INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING", &cid_bytes, data)
.execute(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
sqlx::query!(
"INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING",
&cid_bytes,
data
)
.execute(&self.pool)
.await
.map_err(RepoError::storage)?;
Ok(cid)
}
@@ -56,7 +64,7 @@ impl BlockStore for PostgresBlockStore {
let row = sqlx::query!("SELECT 1 as one FROM blocks WHERE cid = $1", &cid_bytes)
.fetch_optional(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
.map_err(RepoError::storage)?;
Ok(row.is_some())
}
@@ -82,7 +90,7 @@ impl BlockStore for PostgresBlockStore {
)
.execute(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
.map_err(RepoError::storage)?;
Ok(())
}
@@ -98,7 +106,7 @@ impl BlockStore for PostgresBlockStore {
)
.fetch_all(&self.pool)
.await
.map_err(|e| RepoError::storage(e))?;
.map_err(RepoError::storage)?;
let found: std::collections::HashMap<Vec<u8>, Bytes> = rows
.into_iter()
.map(|row| (row.cid, Bytes::from(row.data)))
+15 -7
View File
@@ -51,8 +51,12 @@ impl BlockStore for TrackingBlockStore {
let result = self.inner.get(cid).await?;
if result.is_some() {
match self.read_cids.lock() {
Ok(mut guard) => { guard.insert(*cid); },
Err(poisoned) => { poisoned.into_inner().insert(*cid); },
Ok(mut guard) => {
guard.insert(*cid);
}
Err(poisoned) => {
poisoned.into_inner().insert(*cid);
}
}
}
Ok(result)
@@ -61,8 +65,8 @@ impl BlockStore for TrackingBlockStore {
async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> {
let cid = self.inner.put(data).await?;
match self.written_cids.lock() {
Ok(mut guard) => guard.push(cid.clone()),
Err(poisoned) => poisoned.into_inner().push(cid.clone()),
Ok(mut guard) => guard.push(cid),
Err(poisoned) => poisoned.into_inner().push(cid),
}
Ok(cid)
}
@@ -76,7 +80,7 @@ impl BlockStore for TrackingBlockStore {
blocks: impl IntoIterator<Item = (Cid, Bytes)> + Send,
) -> Result<(), RepoError> {
let blocks: Vec<_> = blocks.into_iter().collect();
let cids: Vec<Cid> = blocks.iter().map(|(cid, _)| cid.clone()).collect();
let cids: Vec<Cid> = blocks.iter().map(|(cid, _)| *cid).collect();
self.inner.put_many(blocks).await?;
match self.written_cids.lock() {
Ok(mut guard) => guard.extend(cids),
@@ -90,8 +94,12 @@ impl BlockStore for TrackingBlockStore {
for (cid, result) in cids.iter().zip(results.iter()) {
if result.is_some() {
match self.read_cids.lock() {
Ok(mut guard) => { guard.insert(*cid); },
Err(poisoned) => { poisoned.into_inner().insert(*cid); },
Ok(mut guard) => {
guard.insert(*cid);
}
Err(poisoned) => {
poisoned.into_inner().insert(*cid);
}
}
}
}
+5 -1
View File
@@ -117,7 +117,11 @@ impl AppState {
let limiter_name = kind.key_prefix();
let (limit, window_ms) = kind.limit_and_window_ms();
if !self.distributed_rate_limiter.check_rate_limit(&key, limit, window_ms).await {
if !self
.distributed_rate_limiter
.check_rate_limit(&key, limit, window_ms)
.await
{
crate::metrics::record_rate_limit_rejection(limiter_name);
return false;
}
+4 -2
View File
@@ -62,7 +62,8 @@ impl BlobStorage for S3BlobStorage {
}
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
let result = self.client
let result = self
.client
.put_object()
.bucket(&self.bucket)
.key(key)
@@ -112,7 +113,8 @@ impl BlobStorage for S3BlobStorage {
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
let result = self.client
let result = self
.client
.delete_object()
.bucket(&self.bucket)
.key(key)
+9 -13
View File
@@ -58,14 +58,17 @@ pub async fn get_blob(
}
Ok(Some(_)) => {}
}
let blob_result = sqlx::query!("SELECT storage_key, mime_type FROM blobs WHERE cid = $1", cid)
.fetch_optional(&state.db)
.await;
let blob_result = sqlx::query!(
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
cid
)
.fetch_optional(&state.db)
.await;
match blob_result {
Ok(Some(row)) => {
let storage_key = &row.storage_key;
let mime_type = &row.mime_type;
match state.blob_store.get(&storage_key).await {
match state.blob_store.get(storage_key).await {
Ok(data) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
@@ -184,15 +187,8 @@ pub async fn list_blobs(
match cids_result {
Ok(cids) => {
let has_more = cids.len() as i64 > limit;
let cids: Vec<String> = cids
.into_iter()
.take(limit as usize)
.collect();
let next_cursor = if has_more {
cids.last().cloned()
} else {
None
};
let cids: Vec<String> = cids.into_iter().take(limit as usize).collect();
let next_cursor = if has_more { cids.last().cloned() } else { None };
(
StatusCode::OK,
Json(ListBlobsOutput {
+4 -2
View File
@@ -24,8 +24,10 @@ pub fn ld_write<W: Write>(mut writer: W, data: &[u8]) -> std::io::Result<()> {
}
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().map_err(|e| format!("Failed to encode CAR header: {:?}", e))?;
let header = CarHeader::new_v1(vec![*root_cid]);
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)
.expect("Writing to Vec<u8> should never fail");

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