extract fediverse as crate
EBO <eric.bouhana@softalys.com> committé le 2026-05-11 21:53
d6ab32e49423a14070f3bcb4ffab3e83bf210163
1 parent(s)
90 fichiers modifiés
+11864
-11519
M
CHANGELOG.md
+67
-0
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
| 7 | 7 | |
| 8 | 8 | ## [Unreleased] |
| 9 | 9 | |
| 10 | +### Changed (BREAKING) | |
| 11 | + | |
| 12 | +- **Extraction du crate `rustwarden-fediverse`** (cf. | |
| 13 | + [`docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md`](docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md)). | |
| 14 | + La fédération ActivityPub vit désormais dans un crate dédié | |
| 15 | + (`crates/rustwarden-fediverse/`, ~9 000 lignes) plutôt que dans le module | |
| 16 | + `rustwarden_core::fediverse`. Les consommateurs externes doivent migrer leurs | |
| 17 | + imports : | |
| 18 | + - `rustwarden_core::fediverse::*` → `rustwarden_fediverse::*` | |
| 19 | + - `rustwarden_core::services::fediverse_*_service` → `rustwarden_fediverse::services::fediverse_*_service` | |
| 20 | + - `rustwarden_core::services::actor_key_service` → `rustwarden_fediverse::services::actor_key_service` | |
| 21 | + - `rustwarden_core::services::remote_identity_service` → `rustwarden_fediverse::services::remote_identity_service` | |
| 22 | + - `rustwarden_core::models::{remote_identity, fediverse_*}` → `rustwarden_fediverse::models::*` | |
| 23 | + - `rustwarden_core::config::FediverseConfig` → `rustwarden_fediverse::FediverseConfig` | |
| 24 | + La feature Cargo `experimental-fediverse` (côté `rustwarden-core`) est | |
| 25 | + supprimée ; côté `rustwarden-ui` et `app` la nouvelle feature s'appelle | |
| 26 | + `fediverse` et tire la dépendance optionnelle `rustwarden-fediverse`. La | |
| 27 | + méthode `RustwardenBuilder::with_fediverse()` est remplacée par un mount | |
| 28 | + explicite : ouvrir la connexion DB tôt, construire | |
| 29 | + `rustwarden_fediverse::routes::router(config, db)`, puis enchaîner | |
| 30 | + `.register_protected_prefix("/fediverse/consent").register_protected_prefix("/fediverse/invite").register_revocation_checker(checker).merge_routes(router)` | |
| 31 | + sur le builder (voir `app/src/main.rs`). Schéma DB inchangé — toutes les | |
| 32 | + migrations restent dans `rustwarden-core/migrations/` (code SQL DSL pur, | |
| 33 | + aucune dépendance sur les modèles Rust). | |
| 34 | +- **`AuthService::new` change de signature** : prend désormais | |
| 35 | + `protected_prefixes: Arc<Vec<String>>` en troisième argument au lieu d'une | |
| 36 | + liste hardcodée. Les consommateurs enregistrent leurs préfixes via | |
| 37 | + `RustwardenBuilder::register_protected_prefix(prefix)`. Les valeurs par défaut | |
| 38 | + installées par le builder (`/home`, `/profile`, `/test-base`, `/settings`, | |
| 39 | + `/admin`) sont préservées ; `rustwarden-fediverse` ajoute `/fediverse/consent` | |
| 40 | + et `/fediverse/invite` lors du mount. | |
| 41 | +- **F10 (révocation remote JWT) via trait** : le check | |
| 42 | + `remote_identities.disabled_at` effectué par `AuthService` au passage d'une | |
| 43 | + route protégée est désormais délégué à un trait | |
| 44 | + [`RemoteIdentityRevocationChecker`](crates/rustwarden-core/src/middleware/revocation.rs) | |
| 45 | + injecté en `Extension`. `rustwarden-fediverse` fournit l'implémentation | |
| 46 | + SeaORM (`SeaOrmRemoteIdentityRevocationChecker`) ; sans le crate de | |
| 47 | + fédération, le check est sauté — comportement correct car aucun remote JWT | |
| 48 | + ne peut être émis. | |
| 49 | +- **Module `identity` (RFC 0008) reste dans `rustwarden-core`** sans gate de | |
| 50 | + feature. Les traits `Identity` et `IdentityResolver` sont des primitives | |
| 51 | + d'auth/RBAC indépendantes de la couche fédération. | |
| 52 | +- **Race `FEDIVERSE_DOMAIN` corrigée dans les tests** : les helpers | |
| 53 | + `test_fediverse_config()` des services `fediverse_auth_service` et | |
| 54 | + `fediverse_invite_service` n'utilisent plus `unsafe { std::env::set_var }` ; | |
| 55 | + ils appellent directement `FediverseConfig::for_tests("test.local")`, ce | |
| 56 | + qui supprime un risque de flakiness inter-tests. | |
| 57 | +- **i18n complétée** : les 5 namespaces fediverse (`fediverse`, | |
| 58 | + `fediverse_nodeinfo`, `fediverse_invites`, `fediverse_connected_apps`, | |
| 59 | + `fediverse_invite_landing`) sont désormais présents dans les 6 locales | |
| 60 | + (fr/en/de/es/it/pt) — auparavant seuls fr et en étaient traduits. | |
| 61 | +- **Tests E2E doivent tourner en série** : `cargo test -p rustwarden-fediverse | |
| 62 | + --test e2e_fediverse -- --test-threads=1`. Les 32 scénarios mutent l'état | |
| 63 | + partagé du testbed Docker (trust list, `kill_domain`, | |
| 64 | + `remote_identities.disabled_at`, `fediverse_trust_mode`) et se cassent en | |
| 65 | + parallèle. La règle est documentée dans | |
| 66 | + [`tests/fediverse-testbed/README.md`](tests/fediverse-testbed/README.md). | |
| 67 | + Le test S3 (`s3_untrusted_domain_is_rejected_with_403`) flippe désormais | |
| 68 | + lui-même `fediverse_trust_mode` en `allowlist` (testbed boot default | |
| 69 | + `open_with_blocklist`) et restore avant l'assertion. | |
| 70 | +- **Validation finale Phase 5** : `cargo build --workspace`, | |
| 71 | + `cargo clippy --workspace --features rustwarden-app/fediverse -- -D warnings` | |
| 72 | + et `cargo test --workspace` passent ; les 32 tests E2E + les tests interop | |
| 73 | + wiremock contre le testbed Docker tri-instance passent en `--test-threads=1`. | |
| 74 | + Le Dockerfile du testbed a été mis à jour pour utiliser la nouvelle feature | |
| 75 | + `fediverse` au lieu de l'ancien `experimental-fediverse`. | |
| 76 | + | |
| 10 | 77 | ### Added |
| 11 | 78 | |
| 12 | 79 | - **Documentation opérateur** : nouveau guide |
M
Cargo.lock
+36
-4
@@ -4129,6 +4129,7 @@ dependencies = [
| 4129 | 4129 | "axum", |
| 4130 | 4130 | "dotenvy", |
| 4131 | 4131 | "rustwarden-core", |
| 4132 | + "rustwarden-fediverse", | |
| 4132 | 4133 | "rustwarden-ui", |
| 4133 | 4134 | "sea-orm", |
| 4134 | 4135 | "serde_json", |
@@ -4154,9 +4155,7 @@ dependencies = [
| 4154 | 4155 | name = "rustwarden-core" |
| 4155 | 4156 | version = "0.1.0" |
| 4156 | 4157 | dependencies = [ |
| 4157 | - "activitypub_federation", | |
| 4158 | 4158 | "aes-gcm", |
| 4159 | - "ammonia", | |
| 4160 | 4159 | "anyhow", |
| 4161 | 4160 | "askama", |
| 4162 | 4161 | "async-trait", |
@@ -4178,7 +4177,6 @@ dependencies = [
| 4178 | 4177 | "openidconnect", |
| 4179 | 4178 | "rand 0.9.2", |
| 4180 | 4179 | "reqwest 0.12.28", |
| 4181 | - "rsa", | |
| 4182 | 4180 | "sea-orm", |
| 4183 | 4181 | "sea-orm-migration", |
| 4184 | 4182 | "serde", |
@@ -4196,7 +4194,6 @@ dependencies = [
| 4196 | 4194 | "tower-sessions-redis-store", |
| 4197 | 4195 | "tracing", |
| 4198 | 4196 | "tracing-subscriber", |
| 4199 | - "url", | |
| 4200 | 4197 | "urlencoding", |
| 4201 | 4198 | "uuid", |
| 4202 | 4199 | "validator", |
@@ -4205,6 +4202,40 @@ dependencies = [
| 4205 | 4202 | ] |
| 4206 | 4203 | |
| 4207 | 4204 | [[package]] |
| 4205 | +name = "rustwarden-fediverse" | |
| 4206 | +version = "0.1.0" | |
| 4207 | +dependencies = [ | |
| 4208 | + "activitypub_federation", | |
| 4209 | + "ammonia", | |
| 4210 | + "async-trait", | |
| 4211 | + "axum", | |
| 4212 | + "base64 0.22.1", | |
| 4213 | + "chrono", | |
| 4214 | + "futures", | |
| 4215 | + "hex", | |
| 4216 | + "jsonwebtoken", | |
| 4217 | + "rand 0.9.2", | |
| 4218 | + "reqwest 0.12.28", | |
| 4219 | + "rsa", | |
| 4220 | + "rustwarden-core", | |
| 4221 | + "sea-orm", | |
| 4222 | + "serde", | |
| 4223 | + "serde_json", | |
| 4224 | + "sha2", | |
| 4225 | + "subtle", | |
| 4226 | + "thiserror 2.0.18", | |
| 4227 | + "tokio", | |
| 4228 | + "tokio-stream", | |
| 4229 | + "tower", | |
| 4230 | + "tracing", | |
| 4231 | + "url", | |
| 4232 | + "urlencoding", | |
| 4233 | + "uuid", | |
| 4234 | + "validator", | |
| 4235 | + "wiremock", | |
| 4236 | +] | |
| 4237 | + | |
| 4238 | +[[package]] | |
| 4208 | 4239 | name = "rustwarden-ui" |
| 4209 | 4240 | version = "0.1.0" |
| 4210 | 4241 | dependencies = [ |
@@ -4216,6 +4247,7 @@ dependencies = [
| 4216 | 4247 | "futures", |
| 4217 | 4248 | "num-format", |
| 4218 | 4249 | "rustwarden-core", |
| 4250 | + "rustwarden-fediverse", | |
| 4219 | 4251 | "sea-orm", |
| 4220 | 4252 | "serde", |
| 4221 | 4253 | "serde_json", |
M
Cargo.toml
+1
-0
@@ -1,6 +1,7 @@
| 1 | 1 | [workspace] |
| 2 | 2 | members = [ |
| 3 | 3 | "crates/rustwarden-core", |
| 4 | + "crates/rustwarden-fediverse", | |
| 4 | 5 | "crates/rustwarden-ui", |
| 5 | 6 | "app", |
| 6 | 7 | "tools/rustwarden-cli", |
M
app/Cargo.toml
+8
-8
@@ -11,22 +11,22 @@ path = "src/main.rs"
| 11 | 11 | |
| 12 | 12 | [features] |
| 13 | 13 | default = [] |
| 14 | -# Active la fédération ActivityPub expérimentale (RFC 0001+). | |
| 15 | -# Compile la couche backend (rustwarden-core) ET les pages UI HTML | |
| 16 | -# d'administration (rustwarden-ui), notamment `/admin/fediverse/settings` | |
| 17 | -# (RFC 0021 Slice A). | |
| 18 | -experimental-fediverse = [ | |
| 19 | - "rustwarden-core/experimental-fediverse", | |
| 20 | - "rustwarden-ui/experimental-fediverse", | |
| 14 | +# Active la fédération ActivityPub (RFC 0001+). | |
| 15 | +# Compile et lie le crate `rustwarden-fediverse` (services, routes, | |
| 16 | +# protocoles) et les pages UI HTML d'administration (rustwarden-ui). | |
| 17 | +fediverse = [ | |
| 18 | + "dep:rustwarden-fediverse", | |
| 19 | + "rustwarden-ui/fediverse", | |
| 21 | 20 | ] |
| 22 | 21 | |
| 23 | 22 | [dependencies] |
| 24 | 23 | rustwarden-core = { path = "../crates/rustwarden-core", features = ["oauth"] } |
| 25 | 24 | rustwarden-ui = { path = "../crates/rustwarden-ui" } |
| 25 | +rustwarden-fediverse = { path = "../crates/rustwarden-fediverse", optional = true } | |
| 26 | +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | |
| 26 | 27 | axum = { version = "0.8", features = ["macros"] } |
| 27 | 28 | tokio = { version = "1", features = ["full"] } |
| 28 | 29 | tower = { version = "0.5", features = ["limit", "util"] } |
| 29 | -sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | |
| 30 | 30 | tracing = "0.1" |
| 31 | 31 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } |
| 32 | 32 | dotenvy = "0.15" |
M
app/src/main.rs
+31
-2
@@ -8,8 +8,37 @@ async fn main() -> anyhow::Result<()> {
| 8 | 8 | .from_env() |
| 9 | 9 | .with_ui(rustwarden_ui::default_routes); |
| 10 | 10 | |
| 11 | - #[cfg(feature = "experimental-fediverse")] | |
| 12 | - let builder = builder.with_fediverse()?; | |
| 11 | + // ActivityPub federation lives in the `rustwarden-fediverse` crate | |
| 12 | + // (cf. docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md). | |
| 13 | + // Enabling the `fediverse` feature links the crate and mounts its | |
| 14 | + // routes onto the application router. The wiring opens a database | |
| 15 | + // connection early so the federation router and the revocation | |
| 16 | + // checker share the same `DatabaseConnection` as the rest of the | |
| 17 | + // framework. | |
| 18 | + #[cfg(feature = "fediverse")] | |
| 19 | + let builder = { | |
| 20 | + use std::sync::Arc; | |
| 21 | + let db_url = std::env::var("DATABASE_URL") | |
| 22 | + .map_err(|_| anyhow::anyhow!("DATABASE_URL must be set for the fediverse feature"))?; | |
| 23 | + let db = sea_orm::Database::connect(&db_url).await?; | |
| 24 | + let fediverse_config = rustwarden_fediverse::FediverseConfig::from_env() | |
| 25 | + .map_err(|e| anyhow::anyhow!("invalid FediverseConfig: {e}"))?; | |
| 26 | + let fediverse_router = | |
| 27 | + rustwarden_fediverse::routes::router(fediverse_config, db.clone()); | |
| 28 | + let checker: Arc< | |
| 29 | + dyn rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker, | |
| 30 | + > = Arc::new( | |
| 31 | + rustwarden_fediverse::services::revocation::SeaOrmRemoteIdentityRevocationChecker::new( | |
| 32 | + db.clone(), | |
| 33 | + ), | |
| 34 | + ); | |
| 35 | + builder | |
| 36 | + .database(db) | |
| 37 | + .register_protected_prefix("/fediverse/consent") | |
| 38 | + .register_protected_prefix("/fediverse/invite") | |
| 39 | + .register_revocation_checker(checker) | |
| 40 | + .merge_routes(fediverse_router) | |
| 41 | + }; | |
| 13 | 42 | |
| 14 | 43 | builder.build().await?.run().await |
| 15 | 44 | } |
M
crates/rustwarden-core/Cargo.toml
+0
-12
@@ -69,23 +69,11 @@ urlencoding = "2"
| 69 | 69 | # OAuth2 / OpenID Connect (optional) |
| 70 | 70 | openidconnect = { version = "4", optional = true } |
| 71 | 71 | reqwest = { version = "0.12", features = ["json", "native-tls"], optional = true } |
| 72 | -# Fediverse / ActivityPub identity federation (optional, RFC 0001+) | |
| 73 | -activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false, optional = true } | |
| 74 | -ammonia = { version = "4", optional = true } | |
| 75 | -url = { version = "2", features = ["serde"], optional = true } | |
| 76 | -# RFC 0005: actor-key storage (RSA-2048 PKCS#8 / SPKI). Pulled only with | |
| 77 | -# `experimental-fediverse`; private keys are encrypted at rest via | |
| 78 | -# `CryptoService` (AES-256-GCM) before being persisted. | |
| 79 | -rsa = { version = "0.9", features = ["pem", "getrandom"], optional = true } | |
| 80 | 72 | |
| 81 | 73 | [features] |
| 82 | 74 | default = [] |
| 83 | 75 | redis-session = ["tower-sessions-redis-store"] |
| 84 | 76 | oauth = ["dep:openidconnect", "dep:reqwest"] |
| 85 | -# RFC 0001: experimental ActivityPub identity federation. Promoted to | |
| 86 | -# `fediverse` when stabilised (>= 2 adopting apps or 30 days without | |
| 87 | -# critical bugs, cf. RUSTWARDEN_EVOLUTION_PROCESS.md §5.1). | |
| 88 | -experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url", "dep:rsa", "dep:reqwest"] | |
| 89 | 77 | |
| 90 | 78 | [lints] |
| 91 | 79 | workspace = true |
M
crates/rustwarden-core/src/app.rs
+60
-67
@@ -132,11 +132,15 @@ pub struct RustwardenBuilder {
| 132 | 132 | locale_dirs: Vec<PathBuf>, |
| 133 | 133 | supported_locales: Vec<String>, |
| 134 | 134 | default_locale: String, |
| 135 | - /// Federation configuration loaded by [`RustwardenBuilder::with_fediverse`]. | |
| 136 | - /// Subsequent fediverse RFCs (0002+) consume this field to mount routes, | |
| 137 | - /// the federation middleware, and the outbound delivery worker. | |
| 138 | - #[cfg(feature = "experimental-fediverse")] | |
| 139 | - pub(crate) fediverse_config: Option<crate::config::FediverseConfig>, | |
| 135 | + /// URL path prefixes that the authentication middleware must protect. | |
| 136 | + /// Filled in by `rustwarden-ui` (and by `rustwarden-fediverse` when | |
| 137 | + /// the consumer mounts its routes). See `register_protected_prefix`. | |
| 138 | + pub(crate) protected_prefixes: Vec<String>, | |
| 139 | + /// Optional revocation checker consulted by `AuthService` for remote | |
| 140 | + /// JWTs (RFC 0018 / F10). Injected by `rustwarden-fediverse` when | |
| 141 | + /// the consumer mounts its routes; left unset for headless apps. | |
| 142 | + pub(crate) revocation_checker: | |
| 143 | + Option<Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>>, | |
| 140 | 144 | } |
| 141 | 145 | |
| 142 | 146 | impl Default for RustwardenBuilder { |
@@ -154,8 +158,14 @@ impl Default for RustwardenBuilder {
| 154 | 158 | locale_dirs: Vec::new(), |
| 155 | 159 | supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()], |
| 156 | 160 | default_locale: "fr".into(), |
| 157 | - #[cfg(feature = "experimental-fediverse")] | |
| 158 | - fediverse_config: None, | |
| 161 | + protected_prefixes: vec![ | |
| 162 | + "/home".into(), | |
| 163 | + "/profile".into(), | |
| 164 | + "/test-base".into(), | |
| 165 | + "/settings".into(), | |
| 166 | + "/admin".into(), | |
| 167 | + ], | |
| 168 | + revocation_checker: None, | |
| 159 | 169 | } |
| 160 | 170 | } |
| 161 | 171 | } |
@@ -222,52 +232,29 @@ impl RustwardenBuilder {
| 222 | 232 | self |
| 223 | 233 | } |
| 224 | 234 | |
| 225 | - /// Enable ActivityPub identity federation on the application (RFC 0001). | |
| 226 | - /// | |
| 227 | - /// Loads [`FediverseConfig`](crate::config::FediverseConfig) from | |
| 228 | - /// environment variables and stores it on the builder. Subsequent | |
| 229 | - /// fediverse RFCs (0002+) consume this configuration to mount the | |
| 230 | - /// federation router, register the federation middleware, and start the | |
| 231 | - /// outbound delivery worker. | |
| 232 | - /// | |
| 233 | - /// At runtime, the federation router additionally honours the | |
| 234 | - /// `app_settings.fediverse_enabled` flag (covered by RFC 0004): when | |
| 235 | - /// `false`, federation routes return `404 Not Found` even though they | |
| 236 | - /// are wired into the Axum app. | |
| 237 | - /// | |
| 238 | - /// **Compile-time opt-in**: this method is only available when the | |
| 239 | - /// `experimental-fediverse` Cargo feature is enabled. Consumers that do | |
| 240 | - /// not enable the feature cannot call it, which is intentional. | |
| 241 | - /// | |
| 242 | - /// # Errors | |
| 243 | - /// | |
| 244 | - /// Returns [`anyhow::Error`] when | |
| 245 | - /// [`FediverseConfig::from_env`](crate::config::FediverseConfig::from_env) | |
| 246 | - /// fails (missing/placeholder domain, insecure base URL outside debug, | |
| 247 | - /// invalid numeric env var, …). | |
| 248 | - /// | |
| 249 | - /// # Example | |
| 250 | - /// | |
| 251 | - /// ```rust,ignore | |
| 252 | - /// use rustwarden_core::app::RustwardenApp; | |
| 253 | - /// | |
| 254 | - /// #[tokio::main] | |
| 255 | - /// async fn main() -> anyhow::Result<()> { | |
| 256 | - /// RustwardenApp::builder() | |
| 257 | - /// .from_env() | |
| 258 | - /// .with_fediverse()? | |
| 259 | - /// .build() | |
| 260 | - /// .await? | |
| 261 | - /// .run() | |
| 262 | - /// .await | |
| 263 | - /// } | |
| 264 | - /// ``` | |
| 265 | - #[cfg(feature = "experimental-fediverse")] | |
| 266 | - pub fn with_fediverse(mut self) -> anyhow::Result<Self> { | |
| 267 | - let config = crate::config::FediverseConfig::from_env() | |
| 268 | - .map_err(|e| anyhow::anyhow!("invalid FediverseConfig: {e}"))?; | |
| 269 | - self.fediverse_config = Some(config); | |
| 270 | - Ok(self) | |
| 235 | + /// Register a URL path prefix that the authentication middleware must | |
| 236 | + /// protect (redirect anonymous visitors to `/login`). The default set | |
| 237 | + /// (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`) is | |
| 238 | + /// installed in [`Default::default`]; consumers and companion crates | |
| 239 | + /// add more on top — for instance `rustwarden-fediverse::mount` adds | |
| 240 | + /// `/fediverse/consent` and `/fediverse/invite`. | |
| 241 | + pub fn register_protected_prefix(mut self, prefix: impl Into<String>) -> Self { | |
| 242 | + self.protected_prefixes.push(prefix.into()); | |
| 243 | + self | |
| 244 | + } | |
| 245 | + | |
| 246 | + /// Register an implementation of | |
| 247 | + /// [`crate::middleware::revocation::RemoteIdentityRevocationChecker`] | |
| 248 | + /// so the auth middleware can refuse remote JWTs whose backing | |
| 249 | + /// identity has been disabled (RFC 0018 / F10). Called by | |
| 250 | + /// `rustwarden_fediverse::mount` ; headless apps without federation | |
| 251 | + /// leave this unset and the middleware skips the check. | |
| 252 | + pub fn register_revocation_checker( | |
| 253 | + mut self, | |
| 254 | + checker: Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>, | |
| 255 | + ) -> Self { | |
| 256 | + self.revocation_checker = Some(checker); | |
| 257 | + self | |
| 271 | 258 | } |
| 272 | 259 | |
| 273 | 260 | /// Enable automatic database migration at startup. |
@@ -474,19 +461,9 @@ impl RustwardenBuilder {
| 474 | 461 | router = router.merge(oauth_routes); |
| 475 | 462 | } |
| 476 | 463 | |
| 477 | - // 7c. Federation routes (RFC 0002 — discovery layer). | |
| 478 | - // Mounted only if the consumer called `with_fediverse()` and the | |
| 479 | - // feature is compiled in. | |
| 480 | - #[cfg(feature = "experimental-fediverse")] | |
| 481 | - if let Some(fediverse_config) = self.fediverse_config.as_ref() { | |
| 482 | - let fediverse_routes = | |
| 483 | - crate::fediverse::routes::router(fediverse_config.clone(), db.clone()); | |
| 484 | - router = router.merge(fediverse_routes); | |
| 485 | - tracing::info!( | |
| 486 | - "ActivityPub federation routes mounted on domain={}", | |
| 487 | - fediverse_config.domain() | |
| 488 | - ); | |
| 489 | - } | |
| 464 | + // Federation routes now live in the `rustwarden-fediverse` crate; | |
| 465 | + // consumers mount them themselves on the returned router (cf. | |
| 466 | + // `app/src/main.rs` calling `rustwarden_fediverse::routes::router`). | |
| 490 | 467 | |
| 491 | 468 | // 8. Apply middleware stack |
| 492 | 469 | let supported = self.supported_locales.clone(); |
@@ -553,12 +530,18 @@ impl RustwardenBuilder {
| 553 | 530 | )) |
| 554 | 531 | }; |
| 555 | 532 | |
| 533 | + let protected_prefixes = std::sync::Arc::new(self.protected_prefixes.clone()); | |
| 534 | + let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes); | |
| 535 | + let revocation_checker = self.revocation_checker.clone(); | |
| 536 | + | |
| 556 | 537 | let app = router.with_state(db).layer( |
| 557 | 538 | ServiceBuilder::new() |
| 558 | 539 | .layer(cors_layer) |
| 559 | 540 | .layer(axum::middleware::from_fn(security_headers_middleware)) |
| 560 | 541 | .layer_fn(move |service| AdminService::new(service, db_for_admin.clone())) |
| 561 | - .layer_fn(move |service| AuthService::new(service, db_for_auth.clone())) | |
| 542 | + .layer_fn(move |service| { | |
| 543 | + AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone()) | |
| 544 | + }) | |
| 562 | 545 | .layer(session_layer) |
| 563 | 546 | .layer(axum::middleware::from_fn(move |req, next| { |
| 564 | 547 | let supported = supported.clone(); |
@@ -572,6 +555,16 @@ impl RustwardenBuilder {
| 572 | 555 | .into_inner(), |
| 573 | 556 | ); |
| 574 | 557 | |
| 558 | + // Wire the revocation checker as an Extension so `AuthService` | |
| 559 | + // can consult it. Registered by `rustwarden-fediverse` via | |
| 560 | + // `RustwardenBuilder::register_revocation_checker`; absent when | |
| 561 | + // the federation crate isn't linked. | |
| 562 | + let app = if let Some(checker) = revocation_checker { | |
| 563 | + app.layer(axum::Extension(checker)) | |
| 564 | + } else { | |
| 565 | + app | |
| 566 | + }; | |
| 567 | + | |
| 575 | 568 | #[cfg(feature = "oauth")] |
| 576 | 569 | let app = app.layer(axum::Extension(oauth_registry)); |
| 577 | 570 |
D
crates/rustwarden-core/src/config/fediverse.rs
+0
-490
@@ -1,490 +0,0 @@
| 1 | -//! Runtime configuration for ActivityPub identity federation (RFC 0001). | |
| 2 | -//! | |
| 3 | -//! This module is gated behind the `experimental-fediverse` Cargo feature. | |
| 4 | -//! It loads federation parameters from environment variables at startup and | |
| 5 | -//! is consumed by [`crate::app::RustwardenBuilder::with_fediverse`] to wire | |
| 6 | -//! the federation router and outbound worker into the Axum application. | |
| 7 | -//! | |
| 8 | -//! See [`docs/rfcs/0001-fediverse-config-and-builder.md`](https://github.com/EBO/rustwarden/blob/main/docs/rfcs/0001-fediverse-config-and-builder.md) | |
| 9 | -//! for the full specification. | |
| 10 | - | |
| 11 | -use std::time::Duration; | |
| 12 | -use url::Url; | |
| 13 | - | |
| 14 | -/// Runtime configuration for ActivityPub identity federation. | |
| 15 | -/// | |
| 16 | -/// Built from environment variables via [`FediverseConfig::from_env`]. Validated | |
| 17 | -/// at construction: missing or placeholder domains, invalid URLs, and HTTP base | |
| 18 | -/// URLs in production are rejected eagerly. | |
| 19 | -/// | |
| 20 | -/// # Example | |
| 21 | -/// | |
| 22 | -/// ```rust,ignore | |
| 23 | -/// use rustwarden_core::config::FediverseConfig; | |
| 24 | -/// | |
| 25 | -/// // SAFETY: doctest fixture — values are stable test constants and the | |
| 26 | -/// // doctest binary serialises env access through cargo's test harness. | |
| 27 | -/// unsafe { | |
| 28 | -/// std::env::set_var("FEDIVERSE_DOMAIN", "example.org"); | |
| 29 | -/// } | |
| 30 | -/// | |
| 31 | -/// let config = FediverseConfig::from_env().expect("valid config"); | |
| 32 | -/// assert_eq!(config.domain(), "example.org"); | |
| 33 | -/// assert_eq!(config.base_url().as_str(), "https://example.org/"); | |
| 34 | -/// ``` | |
| 35 | -#[derive(Debug, Clone)] | |
| 36 | -pub struct FediverseConfig { | |
| 37 | - domain: String, | |
| 38 | - base_url: Url, | |
| 39 | - user_agent: String, | |
| 40 | - authorized_fetch: bool, | |
| 41 | - outbound_workers: usize, | |
| 42 | - outbound_max_attempts: u32, | |
| 43 | - outbound_request_timeout: Duration, | |
| 44 | - fetch_actor_cache_ttl: Duration, | |
| 45 | - auth_challenge_ttl: Duration, | |
| 46 | - debug_local_federation: bool, | |
| 47 | -} | |
| 48 | - | |
| 49 | -/// Errors that can occur while loading or validating [`FediverseConfig`]. | |
| 50 | -#[derive(Debug, thiserror::Error)] | |
| 51 | -pub enum FediverseConfigError { | |
| 52 | - /// `FEDIVERSE_DOMAIN` environment variable is missing or empty. | |
| 53 | - #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")] | |
| 54 | - MissingDomain, | |
| 55 | - | |
| 56 | - /// `FEDIVERSE_DOMAIN` value is a known placeholder | |
| 57 | - /// (e.g. `example.com`, `localhost.localdomain`, `your.tld`). | |
| 58 | - /// Refused regardless of debug mode to prevent accidental production use. | |
| 59 | - #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")] | |
| 60 | - PlaceholderDomain(String), | |
| 61 | - | |
| 62 | - /// Computed base URL is not HTTPS while `debug_local_federation` is off. | |
| 63 | - #[error("base URL `{0}` must use HTTPS in production (set FEDIVERSE_DEBUG_LOCAL_FEDERATION=true for local tests)")] | |
| 64 | - InsecureBaseUrl(String), | |
| 65 | - | |
| 66 | - /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host. | |
| 67 | - #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")] | |
| 68 | - InvalidDomain(String, String), | |
| 69 | - | |
| 70 | - /// A numeric environment variable could not be parsed. | |
| 71 | - #[error("environment variable `{name}` has an invalid value `{value}`: {reason}")] | |
| 72 | - InvalidNumeric { | |
| 73 | - /// Name of the offending environment variable. | |
| 74 | - name: &'static str, | |
| 75 | - /// Raw value that failed to parse. | |
| 76 | - value: String, | |
| 77 | - /// Underlying parse error description. | |
| 78 | - reason: String, | |
| 79 | - }, | |
| 80 | -} | |
| 81 | - | |
| 82 | -impl FediverseConfig { | |
| 83 | - /// Load and validate a [`FediverseConfig`] from environment variables. | |
| 84 | - /// | |
| 85 | - /// # Required variables | |
| 86 | - /// | |
| 87 | - /// - `FEDIVERSE_DOMAIN` — federated domain, no scheme, no path | |
| 88 | - /// (e.g. `example.org`). | |
| 89 | - /// | |
| 90 | - /// # Optional variables (defaults shown) | |
| 91 | - /// | |
| 92 | - /// | Variable | Default | | |
| 93 | - /// |---|---| | |
| 94 | - /// | `FEDIVERSE_AUTHORIZED_FETCH` | `false` | | |
| 95 | - /// | `FEDIVERSE_OUTBOUND_WORKERS` | `4` | | |
| 96 | - /// | `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | `8` | | |
| 97 | - /// | `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | `30` | | |
| 98 | - /// | `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | `86400` (24 h) | | |
| 99 | - /// | `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | `300` (5 min) | | |
| 100 | - /// | `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | `false` | | |
| 101 | - /// | |
| 102 | - /// When `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, HTTP base URLs are | |
| 103 | - /// accepted (for local 2-instance integration tests). In production this | |
| 104 | - /// flag must remain `false` and the resulting base URL must be HTTPS. | |
| 105 | - /// | |
| 106 | - /// # Errors | |
| 107 | - /// | |
| 108 | - /// See [`FediverseConfigError`] for the full failure taxonomy. | |
| 109 | - pub fn from_env() -> Result<Self, FediverseConfigError> { | |
| 110 | - let raw_domain = std::env::var("FEDIVERSE_DOMAIN") | |
| 111 | - .ok() | |
| 112 | - .map(|s| s.trim().to_string()) | |
| 113 | - .filter(|s| !s.is_empty()) | |
| 114 | - .ok_or(FediverseConfigError::MissingDomain)?; | |
| 115 | - | |
| 116 | - if is_placeholder_domain(&raw_domain) { | |
| 117 | - return Err(FediverseConfigError::PlaceholderDomain(raw_domain)); | |
| 118 | - } | |
| 119 | - | |
| 120 | - let debug_local_federation = read_bool_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", false)?; | |
| 121 | - | |
| 122 | - // Accept either a bare domain ("example.org") or a fully-qualified URL | |
| 123 | - // ("http://localhost:3000"). The bare form always becomes HTTPS; the | |
| 124 | - // explicit form is only honoured under debug mode. | |
| 125 | - let (scheme, host_part) = if let Some(rest) = raw_domain.strip_prefix("https://") { | |
| 126 | - ("https", rest.trim_end_matches('/').to_string()) | |
| 127 | - } else if let Some(rest) = raw_domain.strip_prefix("http://") { | |
| 128 | - ("http", rest.trim_end_matches('/').to_string()) | |
| 129 | - } else { | |
| 130 | - ("https", raw_domain.trim_end_matches('/').to_string()) | |
| 131 | - }; | |
| 132 | - | |
| 133 | - if scheme == "http" && !debug_local_federation { | |
| 134 | - return Err(FediverseConfigError::InsecureBaseUrl(format!( | |
| 135 | - "http://{host_part}" | |
| 136 | - ))); | |
| 137 | - } | |
| 138 | - | |
| 139 | - let base_url_str = format!("{scheme}://{host_part}/"); | |
| 140 | - let base_url = Url::parse(&base_url_str) | |
| 141 | - .map_err(|e| FediverseConfigError::InvalidDomain(host_part.clone(), e.to_string()))?; | |
| 142 | - | |
| 143 | - let authorized_fetch = read_bool_env("FEDIVERSE_AUTHORIZED_FETCH", false)?; | |
| 144 | - let outbound_workers = read_numeric_env("FEDIVERSE_OUTBOUND_WORKERS", 4)?; | |
| 145 | - let outbound_max_attempts = read_numeric_env("FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", 8)?; | |
| 146 | - let outbound_request_timeout = | |
| 147 | - Duration::from_secs(read_numeric_env("FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", 30)?); | |
| 148 | - let fetch_actor_cache_ttl = Duration::from_secs(read_numeric_env( | |
| 149 | - "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", | |
| 150 | - 86_400, | |
| 151 | - )?); | |
| 152 | - let auth_challenge_ttl = | |
| 153 | - Duration::from_secs(read_numeric_env("FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", 300)?); | |
| 154 | - | |
| 155 | - let user_agent = format!( | |
| 156 | - "rustwarden/{} (+{})", | |
| 157 | - env!("CARGO_PKG_VERSION"), | |
| 158 | - base_url | |
| 159 | - ); | |
| 160 | - | |
| 161 | - Ok(Self { | |
| 162 | - domain: host_part, | |
| 163 | - base_url, | |
| 164 | - user_agent, | |
| 165 | - authorized_fetch, | |
| 166 | - outbound_workers, | |
| 167 | - outbound_max_attempts, | |
| 168 | - outbound_request_timeout, | |
| 169 | - fetch_actor_cache_ttl, | |
| 170 | - auth_challenge_ttl, | |
| 171 | - debug_local_federation, | |
| 172 | - }) | |
| 173 | - } | |
| 174 | - | |
| 175 | - /// Federated domain (no scheme, no trailing slash). Example: `example.org`. | |
| 176 | - pub fn domain(&self) -> &str { | |
| 177 | - &self.domain | |
| 178 | - } | |
| 179 | - | |
| 180 | - /// Canonical base URL with trailing slash. Example: `https://example.org/`. | |
| 181 | - pub fn base_url(&self) -> &Url { | |
| 182 | - &self.base_url | |
| 183 | - } | |
| 184 | - | |
| 185 | - /// HTTP `User-Agent` to send on outbound federation requests. | |
| 186 | - pub fn user_agent(&self) -> &str { | |
| 187 | - &self.user_agent | |
| 188 | - } | |
| 189 | - | |
| 190 | - /// Whether incoming `GET` requests on actor/object endpoints must be | |
| 191 | - /// signed (Mastodon "secure mode" / Authorized Fetch). | |
| 192 | - pub fn authorized_fetch(&self) -> bool { | |
| 193 | - self.authorized_fetch | |
| 194 | - } | |
| 195 | - | |
| 196 | - /// Number of concurrent worker tasks delivering outbound activities. | |
| 197 | - pub fn outbound_workers(&self) -> usize { | |
| 198 | - self.outbound_workers | |
| 199 | - } | |
| 200 | - | |
| 201 | - /// Maximum delivery attempts before a job is marked `dead`. | |
| 202 | - pub fn outbound_max_attempts(&self) -> u32 { | |
| 203 | - self.outbound_max_attempts | |
| 204 | - } | |
| 205 | - | |
| 206 | - /// Per-request HTTP timeout for outbound deliveries. | |
| 207 | - pub fn outbound_request_timeout(&self) -> Duration { | |
| 208 | - self.outbound_request_timeout | |
| 209 | - } | |
| 210 | - | |
| 211 | - /// TTL for cached remote actor documents. | |
| 212 | - pub fn fetch_actor_cache_ttl(&self) -> Duration { | |
| 213 | - self.fetch_actor_cache_ttl | |
| 214 | - } | |
| 215 | - | |
| 216 | - /// TTL for federated authentication challenges (cf. Phase 2). | |
| 217 | - pub fn auth_challenge_ttl(&self) -> Duration { | |
| 218 | - self.auth_challenge_ttl | |
| 219 | - } | |
| 220 | - | |
| 221 | - /// When `true`, HTTP base URLs are allowed (local 2-instance tests). | |
| 222 | - pub fn debug_local_federation(&self) -> bool { | |
| 223 | - self.debug_local_federation | |
| 224 | - } | |
| 225 | -} | |
| 226 | - | |
| 227 | -/// Hostnames that almost certainly indicate the operator forgot to override | |
| 228 | -/// the placeholder (CLI wizard templates, canonical RFC examples). Refused | |
| 229 | -/// regardless of debug mode. | |
| 230 | -/// | |
| 231 | -/// `example.org` is intentionally **not** included: it is a real IANA-owned | |
| 232 | -/// domain commonly used in tests, doctests, and demos for this framework. | |
| 233 | -const PLACEHOLDER_DOMAINS: &[&str] = &[ | |
| 234 | - "example.com", | |
| 235 | - "example.net", | |
| 236 | - "localhost.localdomain", | |
| 237 | - "your.tld", | |
| 238 | - "your-domain.tld", | |
| 239 | - "domain.tld", | |
| 240 | - "change-me.tld", | |
| 241 | -]; | |
| 242 | - | |
| 243 | -fn is_placeholder_domain(raw: &str) -> bool { | |
| 244 | - let lowered = raw.to_ascii_lowercase(); | |
| 245 | - let stripped = lowered | |
| 246 | - .trim_start_matches("http://") | |
| 247 | - .trim_start_matches("https://") | |
| 248 | - .trim_end_matches('/'); | |
| 249 | - PLACEHOLDER_DOMAINS.contains(&stripped) | |
| 250 | -} | |
| 251 | - | |
| 252 | -fn read_bool_env(key: &'static str, default: bool) -> Result<bool, FediverseConfigError> { | |
| 253 | - match std::env::var(key) { | |
| 254 | - Ok(value) => match value.trim().to_ascii_lowercase().as_str() { | |
| 255 | - "" => Ok(default), | |
| 256 | - "true" | "1" | "yes" | "on" => Ok(true), | |
| 257 | - "false" | "0" | "no" | "off" => Ok(false), | |
| 258 | - other => Err(FediverseConfigError::InvalidNumeric { | |
| 259 | - name: key, | |
| 260 | - value: other.to_string(), | |
| 261 | - reason: "expected boolean (true/false/1/0/yes/no/on/off)".into(), | |
| 262 | - }), | |
| 263 | - }, | |
| 264 | - Err(_) => Ok(default), | |
| 265 | - } | |
| 266 | -} | |
| 267 | - | |
| 268 | -fn read_numeric_env<T>(key: &'static str, default: T) -> Result<T, FediverseConfigError> | |
| 269 | -where | |
| 270 | - T: std::str::FromStr, | |
| 271 | - T::Err: std::fmt::Display, | |
| 272 | -{ | |
| 273 | - match std::env::var(key) { | |
| 274 | - Ok(value) => { | |
| 275 | - let trimmed = value.trim(); | |
| 276 | - if trimmed.is_empty() { | |
| 277 | - return Ok(default); | |
| 278 | - } | |
| 279 | - trimmed | |
| 280 | - .parse::<T>() | |
| 281 | - .map_err(|e| FediverseConfigError::InvalidNumeric { | |
| 282 | - name: key, | |
| 283 | - value: trimmed.to_string(), | |
| 284 | - reason: e.to_string(), | |
| 285 | - }) | |
| 286 | - } | |
| 287 | - Err(_) => Ok(default), | |
| 288 | - } | |
| 289 | -} | |
| 290 | - | |
| 291 | -#[cfg(test)] | |
| 292 | -impl FediverseConfig { | |
| 293 | - /// Build a deterministic config for unit tests, bypassing env-var loading. | |
| 294 | - /// Always HTTPS (`https://{domain}/`), all numeric defaults applied, | |
| 295 | - /// `debug_local_federation = false`. | |
| 296 | - pub(crate) fn for_tests(domain: &str) -> Self { | |
| 297 | - let base_url = Url::parse(&format!("https://{domain}/")) | |
| 298 | - .expect("test domain must produce a valid URL"); | |
| 299 | - Self { | |
| 300 | - domain: domain.to_string(), | |
| 301 | - user_agent: format!("rustwarden/test (+{base_url})"), | |
| 302 | - base_url, | |
| 303 | - authorized_fetch: false, | |
| 304 | - outbound_workers: 4, | |
| 305 | - outbound_max_attempts: 8, | |
| 306 | - outbound_request_timeout: Duration::from_secs(30), | |
| 307 | - fetch_actor_cache_ttl: Duration::from_secs(86_400), | |
| 308 | - auth_challenge_ttl: Duration::from_secs(300), | |
| 309 | - debug_local_federation: false, | |
| 310 | - } | |
| 311 | - } | |
| 312 | -} | |
| 313 | - | |
| 314 | -#[cfg(test)] | |
| 315 | -mod tests { | |
| 316 | - use super::*; | |
| 317 | - use std::sync::Mutex; | |
| 318 | - | |
| 319 | - /// Serialises env-var mutations across tests in this module. Each test | |
| 320 | - /// holds the lock for its full duration and clears the FEDIVERSE_* keyspace | |
| 321 | - /// before setting fixtures, making tests order-independent. | |
| 322 | - static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 323 | - | |
| 324 | - /// Reset every FEDIVERSE_* environment variable so the next test starts | |
| 325 | - /// from a known empty state. | |
| 326 | - fn clear_fediverse_env() { | |
| 327 | - for key in [ | |
| 328 | - "FEDIVERSE_DOMAIN", | |
| 329 | - "FEDIVERSE_AUTHORIZED_FETCH", | |
| 330 | - "FEDIVERSE_OUTBOUND_WORKERS", | |
| 331 | - "FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", | |
| 332 | - "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", | |
| 333 | - "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", | |
| 334 | - "FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", | |
| 335 | - "FEDIVERSE_DEBUG_LOCAL_FEDERATION", | |
| 336 | - ] { | |
| 337 | - // SAFETY: tests in this module are serialised through ENV_LOCK, | |
| 338 | - // and the helper runs before any code that reads these vars in the | |
| 339 | - // same test. No other thread observes the partial state. | |
| 340 | - unsafe { | |
| 341 | - std::env::remove_var(key); | |
| 342 | - } | |
| 343 | - } | |
| 344 | - } | |
| 345 | - | |
| 346 | - /// Set an environment variable for the duration of the surrounding test. | |
| 347 | - fn set_env(key: &str, value: &str) { | |
| 348 | - // SAFETY: same rationale as `clear_fediverse_env` — serialised via | |
| 349 | - // ENV_LOCK in the calling test. | |
| 350 | - unsafe { | |
| 351 | - std::env::set_var(key, value); | |
| 352 | - } | |
| 353 | - } | |
| 354 | - | |
| 355 | - // ─── Test 1 — Happy path ──────────────────────────────────────────── | |
| 356 | - | |
| 357 | - #[test] | |
| 358 | - fn from_env_returns_valid_config_with_required_var_only() { | |
| 359 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 360 | - clear_fediverse_env(); | |
| 361 | - set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 362 | - | |
| 363 | - let config = FediverseConfig::from_env().expect("valid config"); | |
| 364 | - assert_eq!(config.domain(), "example.org"); | |
| 365 | - assert_eq!(config.base_url().as_str(), "https://example.org/"); | |
| 366 | - assert!(!config.authorized_fetch()); | |
| 367 | - assert!(!config.debug_local_federation()); | |
| 368 | - } | |
| 369 | - | |
| 370 | - // ─── Test 2 — Missing domain ──────────────────────────────────────── | |
| 371 | - | |
| 372 | - #[test] | |
| 373 | - fn from_env_returns_missing_domain_when_var_unset() { | |
| 374 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 375 | - clear_fediverse_env(); | |
| 376 | - | |
| 377 | - let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 378 | - assert!( | |
| 379 | - matches!(err, FediverseConfigError::MissingDomain), | |
| 380 | - "got {err:?}" | |
| 381 | - ); | |
| 382 | - } | |
| 383 | - | |
| 384 | - // ─── Test 3 — Placeholder domains ─────────────────────────────────── | |
| 385 | - | |
| 386 | - #[test] | |
| 387 | - fn from_env_returns_placeholder_error_for_known_placeholders() { | |
| 388 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 389 | - for placeholder in ["example.com", "localhost.localdomain", "your.tld"] { | |
| 390 | - clear_fediverse_env(); | |
| 391 | - set_env("FEDIVERSE_DOMAIN", placeholder); | |
| 392 | - | |
| 393 | - let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 394 | - assert!( | |
| 395 | - matches!(err, FediverseConfigError::PlaceholderDomain(ref d) if d == placeholder), | |
| 396 | - "domain={placeholder} got {err:?}" | |
| 397 | - ); | |
| 398 | - } | |
| 399 | - } | |
| 400 | - | |
| 401 | - // ─── Test 4 — Insecure base URL in production ─────────────────────── | |
| 402 | - | |
| 403 | - #[test] | |
| 404 | - fn from_env_rejects_http_base_url_in_production() { | |
| 405 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 406 | - clear_fediverse_env(); | |
| 407 | - // Domain that yields http:// only in non-debug mode is conceptual; | |
| 408 | - // we trigger the path by feeding a hostname that the parser maps | |
| 409 | - // to HTTPS but with debug=false (default). To force HTTP, the | |
| 410 | - // implementation interprets a leading `http://` scheme in the env | |
| 411 | - // value as opt-in to plain HTTP — only valid under debug mode. | |
| 412 | - set_env("FEDIVERSE_DOMAIN", "http://example.org"); | |
| 413 | - | |
| 414 | - let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 415 | - assert!( | |
| 416 | - matches!(err, FediverseConfigError::InsecureBaseUrl(_)), | |
| 417 | - "got {err:?}" | |
| 418 | - ); | |
| 419 | - } | |
| 420 | - | |
| 421 | - // ─── Test 5 — Debug mode allows HTTP ──────────────────────────────── | |
| 422 | - | |
| 423 | - #[test] | |
| 424 | - fn from_env_allows_http_when_debug_local_federation() { | |
| 425 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 426 | - clear_fediverse_env(); | |
| 427 | - set_env("FEDIVERSE_DOMAIN", "http://localhost:3000"); | |
| 428 | - set_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true"); | |
| 429 | - | |
| 430 | - let config = FediverseConfig::from_env().expect("debug HTTP allowed"); | |
| 431 | - assert!(config.debug_local_federation()); | |
| 432 | - assert_eq!(config.base_url().scheme(), "http"); | |
| 433 | - assert_eq!(config.domain(), "localhost:3000"); | |
| 434 | - } | |
| 435 | - | |
| 436 | - // ─── Test 6 — Numeric var invalid ─────────────────────────────────── | |
| 437 | - | |
| 438 | - #[test] | |
| 439 | - fn from_env_returns_invalid_numeric_for_bad_workers_value() { | |
| 440 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 441 | - clear_fediverse_env(); | |
| 442 | - set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 443 | - set_env("FEDIVERSE_OUTBOUND_WORKERS", "not-a-number"); | |
| 444 | - | |
| 445 | - let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 446 | - assert!( | |
| 447 | - matches!( | |
| 448 | - err, | |
| 449 | - FediverseConfigError::InvalidNumeric { name, .. } | |
| 450 | - if name == "FEDIVERSE_OUTBOUND_WORKERS" | |
| 451 | - ), | |
| 452 | - "got {err:?}" | |
| 453 | - ); | |
| 454 | - } | |
| 455 | - | |
| 456 | - // ─── Test 7 — Defaults ────────────────────────────────────────────── | |
| 457 | - | |
| 458 | - #[test] | |
| 459 | - fn defaults_match_documented_values() { | |
| 460 | - let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 461 | - clear_fediverse_env(); | |
| 462 | - set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 463 | - | |
| 464 | - let config = FediverseConfig::from_env().expect("valid config"); | |
| 465 | - assert_eq!(config.outbound_workers(), 4); | |
| 466 | - assert_eq!(config.outbound_max_attempts(), 8); | |
| 467 | - assert_eq!( | |
| 468 | - config.outbound_request_timeout(), | |
| 469 | - Duration::from_secs(30), | |
| 470 | - "outbound_request_timeout default" | |
| 471 | - ); | |
| 472 | - assert_eq!( | |
| 473 | - config.fetch_actor_cache_ttl(), | |
| 474 | - Duration::from_secs(86_400), | |
| 475 | - "fetch_actor_cache_ttl default (24 h)" | |
| 476 | - ); | |
| 477 | - assert_eq!( | |
| 478 | - config.auth_challenge_ttl(), | |
| 479 | - Duration::from_secs(300), | |
| 480 | - "auth_challenge_ttl default (5 min)" | |
| 481 | - ); | |
| 482 | - assert!(!config.authorized_fetch()); | |
| 483 | - assert!(!config.debug_local_federation()); | |
| 484 | - assert!( | |
| 485 | - config.user_agent().contains("rustwarden") || config.user_agent().contains("Rustwarden"), | |
| 486 | - "user_agent={}", | |
| 487 | - config.user_agent() | |
| 488 | - ); | |
| 489 | - } | |
| 490 | -} |
M
crates/rustwarden-core/src/config/mod.rs
+2
-5
@@ -11,15 +11,12 @@ pub mod auth;
| 11 | 11 | pub mod database; |
| 12 | 12 | /// Email (SMTP) configuration. |
| 13 | 13 | pub mod email; |
| 14 | -/// ActivityPub federation configuration (RFC 0001). | |
| 15 | -#[cfg(feature = "experimental-fediverse")] | |
| 16 | -pub mod fediverse; | |
| 17 | 14 | /// OAuth provider configuration. |
| 18 | 15 | #[cfg(feature = "oauth")] |
| 19 | 16 | pub mod oauth; |
| 20 | 17 | |
| 21 | -#[cfg(feature = "experimental-fediverse")] | |
| 22 | -pub use fediverse::{FediverseConfig, FediverseConfigError}; | |
| 18 | +// `FediverseConfig` moved to the `rustwarden-fediverse` crate. | |
| 19 | + | |
| 23 | 20 | /// HTTP server configuration (host, port, TLS). |
| 24 | 21 | pub mod server; |
| 25 | 22 | /// Background task scheduler configuration. |
D
crates/rustwarden-core/src/fediverse/actor.rs
+0
-310
@@ -1,310 +0,0 @@
| 1 | -//! Person actor handler with `Accept` content negotiation. | |
| 2 | -//! | |
| 3 | -//! Mounted at `GET /users/{username}`. | |
| 4 | - | |
| 5 | -use axum::{ | |
| 6 | - Extension, Json, | |
| 7 | - extract::Path, | |
| 8 | - http::{HeaderMap, HeaderValue, StatusCode, header}, | |
| 9 | - response::{IntoResponse, Response}, | |
| 10 | -}; | |
| 11 | -use url::Url; | |
| 12 | - | |
| 13 | -use super::error::FediverseError; | |
| 14 | -use super::resolver::UserSummary; | |
| 15 | -use super::state::FediverseState; | |
| 16 | -use super::types::{EndpointsJson, PersonJson, PublicKeyJson, actor_context}; | |
| 17 | - | |
| 18 | -/// `GET /users/{username}`. | |
| 19 | -/// | |
| 20 | -/// Negotiates between ActivityPub JSON and HTML based on the `Accept` header: | |
| 21 | -/// | |
| 22 | -/// | `Accept` value contains… | Response | | |
| 23 | -/// |---|---| | |
| 24 | -/// | `application/activity+json` | `200` JSON Person | | |
| 25 | -/// | `application/ld+json` | `200` JSON Person | | |
| 26 | -/// | `*/*` (e.g. `curl` default) | `200` JSON Person | | |
| 27 | -/// | (no `Accept` header) | `200` JSON Person (Mastodon convention) | | |
| 28 | -/// | only `text/html` (and UI not mounted at this layer) | `406 Not Acceptable` | | |
| 29 | -pub async fn handle( | |
| 30 | - Path(username): Path<String>, | |
| 31 | - headers: HeaderMap, | |
| 32 | - Extension(state): Extension<FediverseState>, | |
| 33 | -) -> Result<Response, FediverseError> { | |
| 34 | - if !prefers_activitypub(&headers) { | |
| 35 | - return Err(FediverseError::NotAcceptable); | |
| 36 | - } | |
| 37 | - | |
| 38 | - let user = state | |
| 39 | - .resolver | |
| 40 | - .find_local_user(&username) | |
| 41 | - .await? | |
| 42 | - .ok_or(FediverseError::UserNotFound)?; | |
| 43 | - | |
| 44 | - let person = build_person_json(&state, &user)?; | |
| 45 | - | |
| 46 | - Ok(( | |
| 47 | - StatusCode::OK, | |
| 48 | - [( | |
| 49 | - header::CONTENT_TYPE, | |
| 50 | - HeaderValue::from_static("application/activity+json; charset=utf-8"), | |
| 51 | - )], | |
| 52 | - Json(person), | |
| 53 | - ) | |
| 54 | - .into_response()) | |
| 55 | -} | |
| 56 | - | |
| 57 | -/// Inspect an `Accept` header and decide whether the caller will accept the | |
| 58 | -/// ActivityPub JSON representation. | |
| 59 | -/// | |
| 60 | -/// Per Mastodon convention, an absent `Accept` header is treated as a | |
| 61 | -/// preference for ActivityPub (AP `id` URLs are the canonical resource). | |
| 62 | -pub(crate) fn prefers_activitypub(headers: &HeaderMap) -> bool { | |
| 63 | - let Some(value) = headers | |
| 64 | - .get(header::ACCEPT) | |
| 65 | - .and_then(|v| v.to_str().ok()) | |
| 66 | - else { | |
| 67 | - return true; | |
| 68 | - }; | |
| 69 | - | |
| 70 | - let lower = value.to_ascii_lowercase(); | |
| 71 | - lower.contains("application/activity+json") | |
| 72 | - || lower.contains("application/ld+json") | |
| 73 | - || lower.contains("*/*") | |
| 74 | -} | |
| 75 | - | |
| 76 | -/// Build a [`PersonJson`] for a local user, using base URLs derived from the | |
| 77 | -/// federation config. | |
| 78 | -pub(crate) fn build_person_json( | |
| 79 | - state: &FediverseState, | |
| 80 | - user: &UserSummary, | |
| 81 | -) -> Result<PersonJson, FediverseError> { | |
| 82 | - let base = state.config.base_url(); | |
| 83 | - let actor_url = url_join(base, &format!("users/{}", user.username))?; | |
| 84 | - let inbox = url_join(base, &format!("users/{}/inbox", user.username))?; | |
| 85 | - let outbox = url_join(base, &format!("users/{}/outbox", user.username))?; | |
| 86 | - let followers = url_join(base, &format!("users/{}/followers", user.username))?; | |
| 87 | - let following = url_join(base, &format!("users/{}/following", user.username))?; | |
| 88 | - let profile = url_join(base, &format!("@{}", user.username))?; | |
| 89 | - let shared_inbox = url_join(base, "inbox")?; | |
| 90 | - | |
| 91 | - let public_key = user.ap_public_key_pem.as_ref().map(|pem| PublicKeyJson { | |
| 92 | - id: append_fragment(&actor_url, "main-key"), | |
| 93 | - owner: actor_url.clone(), | |
| 94 | - public_key_pem: pem.clone(), | |
| 95 | - }); | |
| 96 | - | |
| 97 | - Ok(PersonJson { | |
| 98 | - context: actor_context(), | |
| 99 | - kind: "Person".to_string(), | |
| 100 | - id: actor_url, | |
| 101 | - preferred_username: user.username.clone(), | |
| 102 | - name: user.display_name.clone(), | |
| 103 | - summary: user.bio.clone(), | |
| 104 | - url: Some(profile), | |
| 105 | - inbox, | |
| 106 | - outbox, | |
| 107 | - followers, | |
| 108 | - following, | |
| 109 | - endpoints: Some(EndpointsJson { shared_inbox }), | |
| 110 | - public_key, | |
| 111 | - manually_approves_followers: false, | |
| 112 | - discoverable: true, | |
| 113 | - }) | |
| 114 | -} | |
| 115 | - | |
| 116 | -fn url_join(base: &Url, path: &str) -> Result<Url, FediverseError> { | |
| 117 | - base.join(path) | |
| 118 | - .map_err(|e| FediverseError::App(crate::error::AppError::Internal(format!( | |
| 119 | - "failed to build actor URL from {base} + {path}: {e}" | |
| 120 | - )))) | |
| 121 | -} | |
| 122 | - | |
| 123 | -fn append_fragment(url: &Url, fragment: &str) -> Url { | |
| 124 | - let mut copy = url.clone(); | |
| 125 | - copy.set_fragment(Some(fragment)); | |
| 126 | - copy | |
| 127 | -} | |
| 128 | - | |
| 129 | -#[cfg(test)] | |
| 130 | -mod tests { | |
| 131 | - use super::*; | |
| 132 | - use crate::config::FediverseConfig; | |
| 133 | - use crate::fediverse::resolver::{LocalUserResolver, UserSummary}; | |
| 134 | - use async_trait::async_trait; | |
| 135 | - use axum::Router; | |
| 136 | - use axum::body::Body; | |
| 137 | - use axum::http::Request; | |
| 138 | - use axum::routing::get; | |
| 139 | - use std::sync::Arc; | |
| 140 | - use tower::ServiceExt; | |
| 141 | - use uuid::Uuid; | |
| 142 | - | |
| 143 | - struct StubResolver { | |
| 144 | - user: Option<UserSummary>, | |
| 145 | - } | |
| 146 | - | |
| 147 | - #[async_trait] | |
| 148 | - impl LocalUserResolver for StubResolver { | |
| 149 | - async fn find_local_user( | |
| 150 | - &self, | |
| 151 | - _username: &str, | |
| 152 | - ) -> Result<Option<UserSummary>, crate::error::AppError> { | |
| 153 | - Ok(self.user.clone()) | |
| 154 | - } | |
| 155 | - async fn count_users_total(&self) -> Result<u64, crate::error::AppError> { | |
| 156 | - Ok(0) | |
| 157 | - } | |
| 158 | - async fn count_users_active_since(&self, _days: u32) -> Result<u64, crate::error::AppError> { | |
| 159 | - Ok(0) | |
| 160 | - } | |
| 161 | - async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> { | |
| 162 | - Ok(0) | |
| 163 | - } | |
| 164 | - } | |
| 165 | - | |
| 166 | - fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router { | |
| 167 | - let state = FediverseState { | |
| 168 | - config: FediverseConfig::for_tests(domain), | |
| 169 | - resolver: Arc::new(StubResolver { user }), | |
| 170 | - }; | |
| 171 | - Router::new() | |
| 172 | - .route("/users/{username}", get(handle)) | |
| 173 | - .layer(Extension(state)) | |
| 174 | - } | |
| 175 | - | |
| 176 | - fn alice() -> UserSummary { | |
| 177 | - UserSummary { | |
| 178 | - id: Uuid::new_v4(), | |
| 179 | - username: "alice".into(), | |
| 180 | - display_name: Some("Alice".into()), | |
| 181 | - bio: Some("<p>Hello</p>".into()), | |
| 182 | - ap_public_key_pem: None, | |
| 183 | - } | |
| 184 | - } | |
| 185 | - | |
| 186 | - async fn body_json(response: Response) -> serde_json::Value { | |
| 187 | - let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 188 | - .await | |
| 189 | - .expect("body bytes"); | |
| 190 | - serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 191 | - } | |
| 192 | - | |
| 193 | - #[tokio::test] | |
| 194 | - async fn actor_returns_person_json_with_ap_accept() { | |
| 195 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 196 | - | |
| 197 | - let response = app | |
| 198 | - .oneshot( | |
| 199 | - Request::builder() | |
| 200 | - .uri("/users/alice") | |
| 201 | - .header(header::ACCEPT, "application/activity+json") | |
| 202 | - .body(Body::empty()) | |
| 203 | - .unwrap(), | |
| 204 | - ) | |
| 205 | - .await | |
| 206 | - .unwrap(); | |
| 207 | - | |
| 208 | - assert_eq!(response.status(), StatusCode::OK); | |
| 209 | - let ct = response | |
| 210 | - .headers() | |
| 211 | - .get(header::CONTENT_TYPE) | |
| 212 | - .and_then(|v| v.to_str().ok()) | |
| 213 | - .unwrap_or_default() | |
| 214 | - .to_string(); | |
| 215 | - assert!( | |
| 216 | - ct.contains("application/activity+json"), | |
| 217 | - "content-type={ct}" | |
| 218 | - ); | |
| 219 | - | |
| 220 | - let body = body_json(response).await; | |
| 221 | - assert_eq!(body["type"], "Person"); | |
| 222 | - assert_eq!(body["id"], "https://example.org/users/alice"); | |
| 223 | - assert_eq!(body["preferredUsername"], "alice"); | |
| 224 | - assert_eq!(body["inbox"], "https://example.org/users/alice/inbox"); | |
| 225 | - assert_eq!(body["outbox"], "https://example.org/users/alice/outbox"); | |
| 226 | - assert_eq!(body["followers"], "https://example.org/users/alice/followers"); | |
| 227 | - assert_eq!(body["following"], "https://example.org/users/alice/following"); | |
| 228 | - assert!(body.get("publicKey").is_none() || body["publicKey"].is_null()); | |
| 229 | - } | |
| 230 | - | |
| 231 | - #[tokio::test] | |
| 232 | - async fn actor_returns_person_json_with_ld_json_accept() { | |
| 233 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 234 | - let response = app | |
| 235 | - .oneshot( | |
| 236 | - Request::builder() | |
| 237 | - .uri("/users/alice") | |
| 238 | - .header( | |
| 239 | - header::ACCEPT, | |
| 240 | - "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", | |
| 241 | - ) | |
| 242 | - .body(Body::empty()) | |
| 243 | - .unwrap(), | |
| 244 | - ) | |
| 245 | - .await | |
| 246 | - .unwrap(); | |
| 247 | - assert_eq!(response.status(), StatusCode::OK); | |
| 248 | - let body = body_json(response).await; | |
| 249 | - assert_eq!(body["type"], "Person"); | |
| 250 | - } | |
| 251 | - | |
| 252 | - #[tokio::test] | |
| 253 | - async fn actor_returns_404_for_unknown_user() { | |
| 254 | - let app = router_with_resolver("example.org", None); | |
| 255 | - let response = app | |
| 256 | - .oneshot( | |
| 257 | - Request::builder() | |
| 258 | - .uri("/users/ghost") | |
| 259 | - .header(header::ACCEPT, "application/activity+json") | |
| 260 | - .body(Body::empty()) | |
| 261 | - .unwrap(), | |
| 262 | - ) | |
| 263 | - .await | |
| 264 | - .unwrap(); | |
| 265 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 266 | - } | |
| 267 | - | |
| 268 | - #[tokio::test] | |
| 269 | - async fn actor_returns_406_for_html_when_no_ui_handler() { | |
| 270 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 271 | - let response = app | |
| 272 | - .oneshot( | |
| 273 | - Request::builder() | |
| 274 | - .uri("/users/alice") | |
| 275 | - .header(header::ACCEPT, "text/html") | |
| 276 | - .body(Body::empty()) | |
| 277 | - .unwrap(), | |
| 278 | - ) | |
| 279 | - .await | |
| 280 | - .unwrap(); | |
| 281 | - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); | |
| 282 | - } | |
| 283 | - | |
| 284 | - #[tokio::test] | |
| 285 | - async fn actor_default_accept_returns_ap_json() { | |
| 286 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 287 | - let response = app | |
| 288 | - .oneshot( | |
| 289 | - Request::builder() | |
| 290 | - .uri("/users/alice") | |
| 291 | - .body(Body::empty()) | |
| 292 | - .unwrap(), | |
| 293 | - ) | |
| 294 | - .await | |
| 295 | - .unwrap(); | |
| 296 | - assert_eq!(response.status(), StatusCode::OK); | |
| 297 | - let body = body_json(response).await; | |
| 298 | - assert_eq!(body["type"], "Person"); | |
| 299 | - } | |
| 300 | - | |
| 301 | - #[test] | |
| 302 | - fn prefers_ap_for_explicit_accept() { | |
| 303 | - let mut h = HeaderMap::new(); | |
| 304 | - h.insert( | |
| 305 | - header::ACCEPT, | |
| 306 | - HeaderValue::from_static("application/activity+json"), | |
| 307 | - ); | |
| 308 | - assert!(prefers_activitypub(&h)); | |
| 309 | - } | |
| 310 | -} |
D
crates/rustwarden-core/src/fediverse/auth_routes.rs
+0
-638
@@ -1,638 +0,0 @@
| 1 | -//! HTTP routes for the AP-native authentication flow (RFC 0013). | |
| 2 | -//! | |
| 3 | -//! Five thin handlers around [`crate::services::fediverse_auth_service::FediverseAuthService`]: | |
| 4 | -//! | |
| 5 | -//! | Path | Verb | Caller side | | |
| 6 | -//! |---|---|---| | |
| 7 | -//! | `/api/v1/auth/login/fediverse/initiate` | POST | client (`b.tld`) | | |
| 8 | -//! | `/api/v1/auth/login/fediverse/status/{id}` | GET | client | | |
| 9 | -//! | `/api/v1/auth/login/fediverse/consume/{id}` | POST | client | | |
| 10 | -//! | `/fediverse/auth/inbox` | POST | IdP (`a.tld`) | | |
| 11 | -//! | `/api/v1/fediverse/auth/consent` | POST | IdP | | |
| 12 | -//! | |
| 13 | -//! Gated by `experimental-fediverse`. | |
| 14 | - | |
| 15 | -use std::sync::Arc; | |
| 16 | -use std::time::Duration; | |
| 17 | - | |
| 18 | -use axum::{ | |
| 19 | - Extension, Json, Router, | |
| 20 | - extract::Path, | |
| 21 | - http::{HeaderMap, StatusCode, header}, | |
| 22 | - routing::{get, post}, | |
| 23 | -}; | |
| 24 | -use sea_orm::DatabaseConnection; | |
| 25 | -use serde::{Deserialize, Serialize}; | |
| 26 | -use uuid::Uuid; | |
| 27 | - | |
| 28 | -use super::error::FediverseError; | |
| 29 | -use crate::config::auth::AuthConfig; | |
| 30 | -use crate::services::fediverse_auth_service::{ | |
| 31 | - ChallengeDecision, FediverseAuthService, InitiateRequest, | |
| 32 | -}; | |
| 33 | -#[cfg(test)] | |
| 34 | -use crate::services::fediverse_auth_service::ChallengeStatus; | |
| 35 | -use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 36 | - | |
| 37 | -/// Per-router state injected into the auth-flow handlers. | |
| 38 | -#[derive(Clone)] | |
| 39 | -pub struct AuthFlowState { | |
| 40 | - /// Same `AuthConfig` as the local login flow — used to mint the | |
| 41 | - /// remote JWT on `consume`. | |
| 42 | - pub auth_config: Arc<AuthConfig>, | |
| 43 | - /// Pluggable signature verifier — used by `auth/inbox` to gate | |
| 44 | - /// inbound `AuthRequest` activities. | |
| 45 | - pub verifier: Arc<dyn SignatureVerifier>, | |
| 46 | - /// TTL of every freshly-initiated challenge. | |
| 47 | - pub challenge_ttl: Duration, | |
| 48 | -} | |
| 49 | - | |
| 50 | -/// `POST /api/v1/auth/login/fediverse/initiate` body. | |
| 51 | -#[derive(Debug, Deserialize)] | |
| 52 | -pub struct InitiateBody { | |
| 53 | - /// `@user@domain` handle the user typed. | |
| 54 | - pub handle: String, | |
| 55 | - /// Optional URL to redirect to after successful consume. | |
| 56 | - pub redirect_url: Option<String>, | |
| 57 | -} | |
| 58 | - | |
| 59 | -/// `POST /api/v1/auth/login/fediverse/initiate` 201 response. | |
| 60 | -#[derive(Debug, Serialize)] | |
| 61 | -pub struct InitiateResponse { | |
| 62 | - /// Stable challenge identifier. | |
| 63 | - pub challenge_id: Uuid, | |
| 64 | - /// Cryptographic nonce embedded in the AP `AuthRequest` activity. | |
| 65 | - pub nonce: String, | |
| 66 | - /// RFC3339 expiry timestamp. | |
| 67 | - pub expires_at: String, | |
| 68 | -} | |
| 69 | - | |
| 70 | -/// `GET /api/v1/auth/login/fediverse/status/{id}` 200 response. | |
| 71 | -#[derive(Debug, Serialize)] | |
| 72 | -pub struct StatusResponse { | |
| 73 | - /// Same identifier as the request. | |
| 74 | - pub challenge_id: Uuid, | |
| 75 | - /// Wire form (`pending`, `approved`, `rejected`, `expired`, `consumed`). | |
| 76 | - pub status: String, | |
| 77 | -} | |
| 78 | - | |
| 79 | -/// `POST /api/v1/auth/login/fediverse/consume/{id}` 200 response. | |
| 80 | -#[derive(Debug, Serialize)] | |
| 81 | -pub struct ConsumeResponse { | |
| 82 | - /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`). | |
| 83 | - pub jwt: String, | |
| 84 | - /// FK of the federated identity the JWT speaks for. | |
| 85 | - pub remote_identity_id: Uuid, | |
| 86 | - /// `@user@domain` handle for log/audit cohesion. | |
| 87 | - pub handle: String, | |
| 88 | -} | |
| 89 | - | |
| 90 | -/// `POST /api/v1/fediverse/auth/consent` body. | |
| 91 | -#[derive(Debug, Deserialize)] | |
| 92 | -pub struct ConsentBody { | |
| 93 | - /// Challenge being decided on. | |
| 94 | - pub challenge_id: Uuid, | |
| 95 | - /// `"approve"` or `"reject"`. | |
| 96 | - pub decision: String, | |
| 97 | - /// On approve only: which `remote_identities.id` is bound to the | |
| 98 | - /// IdP's user that clicked approve. The handler trusts this value | |
| 99 | - /// because the request itself is authenticated as the IdP user | |
| 100 | - /// (CSRF-protected by the consumer middleware). | |
| 101 | - pub remote_identity_id: Option<Uuid>, | |
| 102 | -} | |
| 103 | - | |
| 104 | -// ─── Handlers ──────────────────────────────────────────────────────── | |
| 105 | - | |
| 106 | -async fn initiate( | |
| 107 | - Extension(state): Extension<AuthFlowState>, | |
| 108 | - Extension(db): Extension<DatabaseConnection>, | |
| 109 | - Json(body): Json<InitiateBody>, | |
| 110 | -) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> { | |
| 111 | - let challenge = FediverseAuthService::initiate( | |
| 112 | - &db, | |
| 113 | - InitiateRequest { | |
| 114 | - handle: body.handle, | |
| 115 | - redirect_url: body.redirect_url, | |
| 116 | - }, | |
| 117 | - state.challenge_ttl, | |
| 118 | - ) | |
| 119 | - .await?; | |
| 120 | - Ok(( | |
| 121 | - StatusCode::CREATED, | |
| 122 | - Json(InitiateResponse { | |
| 123 | - challenge_id: challenge.id, | |
| 124 | - nonce: challenge.nonce, | |
| 125 | - expires_at: challenge.expires_at.to_rfc3339(), | |
| 126 | - }), | |
| 127 | - )) | |
| 128 | -} | |
| 129 | - | |
| 130 | -async fn status( | |
| 131 | - Extension(_state): Extension<AuthFlowState>, | |
| 132 | - Extension(db): Extension<DatabaseConnection>, | |
| 133 | - Path(challenge_id): Path<Uuid>, | |
| 134 | -) -> Result<Json<StatusResponse>, FediverseError> { | |
| 135 | - let model = FediverseAuthService::status(&db, challenge_id).await?; | |
| 136 | - Ok(Json(StatusResponse { | |
| 137 | - challenge_id: model.id, | |
| 138 | - status: model.status, | |
| 139 | - })) | |
| 140 | -} | |
| 141 | - | |
| 142 | -async fn consume( | |
| 143 | - Extension(state): Extension<AuthFlowState>, | |
| 144 | - Extension(db): Extension<DatabaseConnection>, | |
| 145 | - Path(challenge_id): Path<Uuid>, | |
| 146 | -) -> Result<Json<ConsumeResponse>, FediverseError> { | |
| 147 | - let session = | |
| 148 | - FediverseAuthService::consume(&db, &state.auth_config, challenge_id).await?; | |
| 149 | - Ok(Json(ConsumeResponse { | |
| 150 | - jwt: session.jwt, | |
| 151 | - remote_identity_id: session.remote_identity_id, | |
| 152 | - handle: session.handle, | |
| 153 | - })) | |
| 154 | -} | |
| 155 | - | |
| 156 | -async fn auth_inbox( | |
| 157 | - Extension(state): Extension<AuthFlowState>, | |
| 158 | - Extension(db): Extension<DatabaseConnection>, | |
| 159 | - headers: HeaderMap, | |
| 160 | - body: axum::body::Bytes, | |
| 161 | -) -> Result<StatusCode, FediverseError> { | |
| 162 | - let signature = headers | |
| 163 | - .get(header::HeaderName::from_static("signature")) | |
| 164 | - .and_then(|v| v.to_str().ok()) | |
| 165 | - .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?; | |
| 166 | - | |
| 167 | - // Parse the body once — we need both the actor (for verification) | |
| 168 | - // and the full payload (for `persist_inbound_request` below). | |
| 169 | - let body_json = serde_json::from_slice::<serde_json::Value>(&body) | |
| 170 | - .map_err(|e| FediverseError::BadRequest(format!("malformed AuthRequest JSON: {e}")))?; | |
| 171 | - let actor = body_json | |
| 172 | - .get("actor") | |
| 173 | - .and_then(|v| v.as_str()) | |
| 174 | - .map(str::to_string) | |
| 175 | - .unwrap_or_default(); | |
| 176 | - | |
| 177 | - let _ = signature; // presence already enforced above | |
| 178 | - | |
| 179 | - // F8 / RFC 0027 — enforce trust policy BEFORE signature verification | |
| 180 | - // (and before any actor fetch). A hostile but signature-valid | |
| 181 | - // instance must be rejected here based on its domain alone. | |
| 182 | - let source_domain = url::Url::parse(&actor) | |
| 183 | - .ok() | |
| 184 | - .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 185 | - .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 186 | - crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 187 | - .assert_domain_allowed(&source_domain) | |
| 188 | - .await | |
| 189 | - .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 190 | - | |
| 191 | - state | |
| 192 | - .verifier | |
| 193 | - .verify(&VerifyCtx { | |
| 194 | - actor_ap_id: &actor, | |
| 195 | - method: "post", | |
| 196 | - path: "/fediverse/auth/inbox", | |
| 197 | - headers: &headers, | |
| 198 | - body: &body, | |
| 199 | - public_key_pem: "", | |
| 200 | - }) | |
| 201 | - .await | |
| 202 | - .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; | |
| 203 | - | |
| 204 | - // Dispatch on the activity `type` (RFC 0025). | |
| 205 | - // | |
| 206 | - // - `AuthRequest` → IdP side: persist a Pending challenge tied to | |
| 207 | - // the requester (RFC 0006 Phase 2). | |
| 208 | - // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side: | |
| 209 | - // propagate the IdP's decision into the local challenge (RFC 0025). | |
| 210 | - // - Anything else → AP §7 says ignore unknown types silently; we | |
| 211 | - // return 202 and log to ease debugging. | |
| 212 | - let activity_type = body_json | |
| 213 | - .get("type") | |
| 214 | - .and_then(|v| v.as_str()) | |
| 215 | - .unwrap_or("") | |
| 216 | - .to_string(); | |
| 217 | - | |
| 218 | - match activity_type.as_str() { | |
| 219 | - "AuthRequest" => { | |
| 220 | - // Resolve this IdP's canonical domain from the env (set by | |
| 221 | - // `with_fediverse()` at app boot) for the target-validation | |
| 222 | - // check. Strip scheme/trailing-slash so the comparison against | |
| 223 | - // `target`'s bare host part inside `persist_inbound_request` | |
| 224 | - // works whether FEDIVERSE_DOMAIN was set as `example.org` or | |
| 225 | - // `http://example.org`. (TODO: thread the parsed | |
| 226 | - // FediverseConfig in via extension to avoid env reads here.) | |
| 227 | - let idp_domain = std::env::var("FEDIVERSE_DOMAIN") | |
| 228 | - .unwrap_or_default() | |
| 229 | - .trim_start_matches("http://") | |
| 230 | - .trim_start_matches("https://") | |
| 231 | - .trim_end_matches('/') | |
| 232 | - .to_ascii_lowercase(); | |
| 233 | - FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) | |
| 234 | - .await | |
| 235 | - .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 236 | - } | |
| 237 | - "Accept" | "Reject" => { | |
| 238 | - FediverseAuthService::record_inbound_decision(&db, &body_json) | |
| 239 | - .await | |
| 240 | - .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 241 | - } | |
| 242 | - other => { | |
| 243 | - tracing::debug!( | |
| 244 | - activity_type = other, | |
| 245 | - "auth_inbox: ignoring unknown activity type" | |
| 246 | - ); | |
| 247 | - } | |
| 248 | - } | |
| 249 | - | |
| 250 | - Ok(StatusCode::ACCEPTED) | |
| 251 | -} | |
| 252 | - | |
| 253 | -async fn consent( | |
| 254 | - Extension(_state): Extension<AuthFlowState>, | |
| 255 | - Extension(db): Extension<DatabaseConnection>, | |
| 256 | - Json(body): Json<ConsentBody>, | |
| 257 | -) -> Result<StatusCode, FediverseError> { | |
| 258 | - let decision = match body.decision.as_str() { | |
| 259 | - "approve" => { | |
| 260 | - let remote = body.remote_identity_id.ok_or_else(|| { | |
| 261 | - FediverseError::BadRequest( | |
| 262 | - "approve requires remote_identity_id".into(), | |
| 263 | - ) | |
| 264 | - })?; | |
| 265 | - ChallengeDecision::Approve { | |
| 266 | - remote_identity_id: remote, | |
| 267 | - } | |
| 268 | - } | |
| 269 | - "reject" => ChallengeDecision::Reject, | |
| 270 | - other => { | |
| 271 | - return Err(FediverseError::BadRequest(format!( | |
| 272 | - "invalid decision '{other}'", | |
| 273 | - ))); | |
| 274 | - } | |
| 275 | - }; | |
| 276 | - FediverseAuthService::record_decision(&db, body.challenge_id, decision).await?; | |
| 277 | - Ok(StatusCode::OK) | |
| 278 | -} | |
| 279 | - | |
| 280 | -/// Build the auth-flow router. Mounted by | |
| 281 | -/// [`crate::fediverse::routes::router_with`] alongside the discovery | |
| 282 | -/// + lifecycle inbox routes. | |
| 283 | -pub fn router(state: AuthFlowState) -> Router<DatabaseConnection> { | |
| 284 | - Router::new() | |
| 285 | - .route("/api/v1/auth/login/fediverse/initiate", post(initiate)) | |
| 286 | - .route( | |
| 287 | - "/api/v1/auth/login/fediverse/status/{challenge_id}", | |
| 288 | - get(status), | |
| 289 | - ) | |
| 290 | - .route( | |
| 291 | - "/api/v1/auth/login/fediverse/consume/{challenge_id}", | |
| 292 | - post(consume), | |
| 293 | - ) | |
| 294 | - .route("/fediverse/auth/inbox", post(auth_inbox)) | |
| 295 | - .route("/api/v1/fediverse/auth/consent", post(consent)) | |
| 296 | - .layer(Extension(state)) | |
| 297 | -} | |
| 298 | - | |
| 299 | -#[cfg(test)] | |
| 300 | -mod tests { | |
| 301 | - use super::*; | |
| 302 | - use crate::error::AppError; | |
| 303 | - use crate::models::fediverse_auth_challenge; | |
| 304 | - use async_trait::async_trait; | |
| 305 | - use axum::body::Body; | |
| 306 | - use axum::http::Request; | |
| 307 | - use chrono::{Duration as ChronoDuration, Utc}; | |
| 308 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 309 | - use tower::ServiceExt; | |
| 310 | - | |
| 311 | - /// F8 enforcement helper for unit tests: pre-queues three mock | |
| 312 | - /// results (app_settings.fediverse_enabled=true, | |
| 313 | - /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so | |
| 314 | - /// `FediverseTrustService::assert_domain_allowed` answers Ok for | |
| 315 | - /// any well-formed actor URL. | |
| 316 | - fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 317 | - use crate::models::{app_settings, fediverse_blocked_domain}; | |
| 318 | - let now = Utc::now(); | |
| 319 | - mock.append_query_results([vec![app_settings::Model { | |
| 320 | - id: Uuid::new_v4(), | |
| 321 | - key: "fediverse_enabled".to_string(), | |
| 322 | - value: "true".to_string(), | |
| 323 | - description: None, | |
| 324 | - updated_by: None, | |
| 325 | - updated_at: now, | |
| 326 | - }]]) | |
| 327 | - .append_query_results([vec![app_settings::Model { | |
| 328 | - id: Uuid::new_v4(), | |
| 329 | - key: "fediverse_trust_mode".to_string(), | |
| 330 | - value: "open_with_blocklist".to_string(), | |
| 331 | - description: None, | |
| 332 | - updated_by: None, | |
| 333 | - updated_at: now, | |
| 334 | - }]]) | |
| 335 | - .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 336 | - } | |
| 337 | - | |
| 338 | - fn test_auth_config() -> AuthConfig { | |
| 339 | - AuthConfig { | |
| 340 | - jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 341 | - .to_string(), | |
| 342 | - jwt_expiration_minutes: 15, | |
| 343 | - jwt_issuer: "rustwarden-test".to_string(), | |
| 344 | - refresh_token_expiration_days: 7, | |
| 345 | - remember_me_expiration_days: 30, | |
| 346 | - session_timeout_minutes: 30, | |
| 347 | - session_backend: crate::config::auth::SessionBackend::Memory, | |
| 348 | - redis_url: None, | |
| 349 | - rate_limit_login_per_minute: 5, | |
| 350 | - rate_limit_refresh_per_minute: 10, | |
| 351 | - rate_limit_general_per_minute: 100, | |
| 352 | - cookie_secure: false, | |
| 353 | - cookie_same_site: "Strict".to_string(), | |
| 354 | - } | |
| 355 | - } | |
| 356 | - | |
| 357 | - struct AlwaysAccept; | |
| 358 | - | |
| 359 | - #[async_trait] | |
| 360 | - impl SignatureVerifier for AlwaysAccept { | |
| 361 | - async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 362 | - Ok(()) | |
| 363 | - } | |
| 364 | - } | |
| 365 | - | |
| 366 | - fn build_router(db: DatabaseConnection) -> Router { | |
| 367 | - let state = AuthFlowState { | |
| 368 | - auth_config: Arc::new(test_auth_config()), | |
| 369 | - verifier: Arc::new(AlwaysAccept), | |
| 370 | - challenge_ttl: Duration::from_secs(300), | |
| 371 | - }; | |
| 372 | - router(state) | |
| 373 | - .layer(Extension(db)) | |
| 374 | - .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) | |
| 375 | - } | |
| 376 | - | |
| 377 | - fn fresh_challenge(id: Uuid) -> fediverse_auth_challenge::Model { | |
| 378 | - let now = Utc::now(); | |
| 379 | - fediverse_auth_challenge::Model { | |
| 380 | - id, | |
| 381 | - nonce: "nonce-xxx".into(), | |
| 382 | - handle: "alice@a.tld".into(), | |
| 383 | - domain: "a.tld".into(), | |
| 384 | - requested_remote_id: None, | |
| 385 | - status: ChallengeStatus::Pending.as_str().into(), | |
| 386 | - redirect_url: None, | |
| 387 | - created_at: now, | |
| 388 | - expires_at: now + ChronoDuration::minutes(5), | |
| 389 | - consumed_at: None, | |
| 390 | - } | |
| 391 | - } | |
| 392 | - | |
| 393 | - fn req_post(uri: &str, body: &str) -> Request<Body> { | |
| 394 | - Request::builder() | |
| 395 | - .uri(uri) | |
| 396 | - .method("POST") | |
| 397 | - .header("content-type", "application/json") | |
| 398 | - .body(Body::from(body.to_string())) | |
| 399 | - .unwrap() | |
| 400 | - } | |
| 401 | - | |
| 402 | - fn req_get(uri: &str) -> Request<Body> { | |
| 403 | - Request::builder() | |
| 404 | - .uri(uri) | |
| 405 | - .method("GET") | |
| 406 | - .body(Body::empty()) | |
| 407 | - .unwrap() | |
| 408 | - } | |
| 409 | - | |
| 410 | - // ─── initiate ──────────────────────────────────────────────────── | |
| 411 | - | |
| 412 | - #[tokio::test] | |
| 413 | - async fn initiate_returns_201_with_challenge_id() { | |
| 414 | - let row = fresh_challenge(Uuid::new_v4()); | |
| 415 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 416 | - .append_query_results([vec![row]]) | |
| 417 | - .into_connection(); | |
| 418 | - let body = serde_json::json!({ | |
| 419 | - "handle": "alice@a.tld", | |
| 420 | - }) | |
| 421 | - .to_string(); | |
| 422 | - let resp = build_router(db) | |
| 423 | - .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) | |
| 424 | - .await | |
| 425 | - .unwrap(); | |
| 426 | - assert_eq!(resp.status(), StatusCode::CREATED); | |
| 427 | - } | |
| 428 | - | |
| 429 | - #[tokio::test] | |
| 430 | - async fn initiate_returns_400_for_invalid_handle() { | |
| 431 | - let db: DatabaseConnection = | |
| 432 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 433 | - let body = serde_json::json!({ | |
| 434 | - "handle": "no-at-sign", | |
| 435 | - }) | |
| 436 | - .to_string(); | |
| 437 | - let resp = build_router(db) | |
| 438 | - .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) | |
| 439 | - .await | |
| 440 | - .unwrap(); | |
| 441 | - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 442 | - } | |
| 443 | - | |
| 444 | - // ─── status ────────────────────────────────────────────────────── | |
| 445 | - | |
| 446 | - #[tokio::test] | |
| 447 | - async fn status_returns_pending_for_fresh_challenge() { | |
| 448 | - let id = Uuid::new_v4(); | |
| 449 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 450 | - .append_query_results([vec![fresh_challenge(id)]]) | |
| 451 | - .into_connection(); | |
| 452 | - let resp = build_router(db) | |
| 453 | - .oneshot(req_get(&format!( | |
| 454 | - "/api/v1/auth/login/fediverse/status/{id}" | |
| 455 | - ))) | |
| 456 | - .await | |
| 457 | - .unwrap(); | |
| 458 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 459 | - } | |
| 460 | - | |
| 461 | - #[tokio::test] | |
| 462 | - async fn status_returns_404_for_unknown_id() { | |
| 463 | - let id = Uuid::new_v4(); | |
| 464 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 465 | - .append_query_results([Vec::<fediverse_auth_challenge::Model>::new()]) | |
| 466 | - .into_connection(); | |
| 467 | - let resp = build_router(db) | |
| 468 | - .oneshot(req_get(&format!( | |
| 469 | - "/api/v1/auth/login/fediverse/status/{id}" | |
| 470 | - ))) | |
| 471 | - .await | |
| 472 | - .unwrap(); | |
| 473 | - assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 474 | - } | |
| 475 | - | |
| 476 | - // ─── consume ───────────────────────────────────────────────────── | |
| 477 | - | |
| 478 | - #[tokio::test] | |
| 479 | - async fn consume_returns_jwt_for_approved_challenge() { | |
| 480 | - let id = Uuid::new_v4(); | |
| 481 | - let remote_id = Uuid::new_v4(); | |
| 482 | - let mut row = fresh_challenge(id); | |
| 483 | - row.status = ChallengeStatus::Approved.as_str().into(); | |
| 484 | - row.requested_remote_id = Some(remote_id); | |
| 485 | - let mut after = row.clone(); | |
| 486 | - after.status = ChallengeStatus::Consumed.as_str().into(); | |
| 487 | - after.consumed_at = Some(Utc::now()); | |
| 488 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 489 | - .append_query_results([vec![row]]) | |
| 490 | - .append_query_results([vec![after]]) | |
| 491 | - .append_exec_results([MockExecResult { | |
| 492 | - last_insert_id: 0, | |
| 493 | - rows_affected: 1, | |
| 494 | - }]) | |
| 495 | - .into_connection(); | |
| 496 | - let resp = build_router(db) | |
| 497 | - .oneshot(req_post( | |
| 498 | - &format!("/api/v1/auth/login/fediverse/consume/{id}"), | |
| 499 | - "", | |
| 500 | - )) | |
| 501 | - .await | |
| 502 | - .unwrap(); | |
| 503 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 504 | - } | |
| 505 | - | |
| 506 | - #[tokio::test] | |
| 507 | - async fn consume_returns_400_when_pending() { | |
| 508 | - let id = Uuid::new_v4(); | |
| 509 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 510 | - .append_query_results([vec![fresh_challenge(id)]]) | |
| 511 | - .into_connection(); | |
| 512 | - let resp = build_router(db) | |
| 513 | - .oneshot(req_post( | |
| 514 | - &format!("/api/v1/auth/login/fediverse/consume/{id}"), | |
| 515 | - "", | |
| 516 | - )) | |
| 517 | - .await | |
| 518 | - .unwrap(); | |
| 519 | - // Validation error from the service maps to 400 via AppError. | |
| 520 | - assert!( | |
| 521 | - resp.status() == StatusCode::BAD_REQUEST | |
| 522 | - || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 523 | - "expected 400/422, got {}", | |
| 524 | - resp.status() | |
| 525 | - ); | |
| 526 | - } | |
| 527 | - | |
| 528 | - // ─── auth_inbox ────────────────────────────────────────────────── | |
| 529 | - | |
| 530 | - #[tokio::test] | |
| 531 | - async fn auth_inbox_returns_400_when_requester_actor_unknown() { | |
| 532 | - // Phase 2 (RFC 0006) enriches `auth_inbox` to persist a | |
| 533 | - // challenge bound to the requester's `remote_identities.id`. | |
| 534 | - // When the row is absent the service returns Validation, which | |
| 535 | - // maps to a 400 BadRequest — NOT a 202. | |
| 536 | - let body = serde_json::json!({ | |
| 537 | - "type": "AuthRequest", | |
| 538 | - "actor": "https://unknown.tld", | |
| 539 | - "target": "alice@a.tld", | |
| 540 | - "nonce": "n", | |
| 541 | - }) | |
| 542 | - .to_string(); | |
| 543 | - let req = Request::builder() | |
| 544 | - .uri("/fediverse/auth/inbox") | |
| 545 | - .method("POST") | |
| 546 | - .header("signature", "keyId=...") | |
| 547 | - .header("content-type", "application/activity+json") | |
| 548 | - .body(Body::from(body)) | |
| 549 | - .unwrap(); | |
| 550 | - // Empty remote_identities cache → persist_inbound_request rejects. | |
| 551 | - // F8 needs permissive trust mock prepended so enforcement passes | |
| 552 | - // and the BadRequest assertion catches the persist_inbound_request | |
| 553 | - // path, not the trust gate. | |
| 554 | - // | |
| 555 | - // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race | |
| 556 | - // condition risk with concurrent build_*_payload tests that read | |
| 557 | - // env via FediverseConfig::from_env(). Whatever value is in env | |
| 558 | - // (or none), the assertion still holds: either the target domain | |
| 559 | - // mismatches and persist_inbound_request returns BadRequest, or | |
| 560 | - // the remote_identities lookup misses and also returns BadRequest. | |
| 561 | - let db: DatabaseConnection = | |
| 562 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 563 | - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 564 | - .into_connection(); | |
| 565 | - let resp = build_router(db).oneshot(req).await.unwrap(); | |
| 566 | - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 567 | - } | |
| 568 | - | |
| 569 | - #[tokio::test] | |
| 570 | - async fn auth_inbox_returns_401_when_signature_missing() { | |
| 571 | - let req = Request::builder() | |
| 572 | - .uri("/fediverse/auth/inbox") | |
| 573 | - .method("POST") | |
| 574 | - .body(Body::from("{}")) | |
| 575 | - .unwrap(); | |
| 576 | - let db: DatabaseConnection = | |
| 577 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 578 | - let resp = build_router(db).oneshot(req).await.unwrap(); | |
| 579 | - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 580 | - } | |
| 581 | - | |
| 582 | - // ─── consent ───────────────────────────────────────────────────── | |
| 583 | - | |
| 584 | - #[tokio::test] | |
| 585 | - async fn consent_approve_marks_challenge_approved() { | |
| 586 | - let id = Uuid::new_v4(); | |
| 587 | - let remote_id = Uuid::new_v4(); | |
| 588 | - let row = fresh_challenge(id); | |
| 589 | - let mut after = row.clone(); | |
| 590 | - after.status = ChallengeStatus::Approved.as_str().into(); | |
| 591 | - after.requested_remote_id = Some(remote_id); | |
| 592 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 593 | - .append_query_results([vec![row]]) | |
| 594 | - .append_query_results([vec![after]]) | |
| 595 | - .append_exec_results([MockExecResult { | |
| 596 | - last_insert_id: 0, | |
| 597 | - rows_affected: 1, | |
| 598 | - }]) | |
| 599 | - .into_connection(); | |
| 600 | - let body = serde_json::json!({ | |
| 601 | - "challenge_id": id, | |
| 602 | - "decision": "approve", | |
| 603 | - "remote_identity_id": remote_id, | |
| 604 | - }) | |
| 605 | - .to_string(); | |
| 606 | - let resp = build_router(db) | |
| 607 | - .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) | |
| 608 | - .await | |
| 609 | - .unwrap(); | |
| 610 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 611 | - } | |
| 612 | - | |
| 613 | - #[tokio::test] | |
| 614 | - async fn consent_reject_marks_challenge_rejected() { | |
| 615 | - let id = Uuid::new_v4(); | |
| 616 | - let row = fresh_challenge(id); | |
| 617 | - let mut after = row.clone(); | |
| 618 | - after.status = ChallengeStatus::Rejected.as_str().into(); | |
| 619 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 620 | - .append_query_results([vec![row]]) | |
| 621 | - .append_query_results([vec![after]]) | |
| 622 | - .append_exec_results([MockExecResult { | |
| 623 | - last_insert_id: 0, | |
| 624 | - rows_affected: 1, | |
| 625 | - }]) | |
| 626 | - .into_connection(); | |
| 627 | - let body = serde_json::json!({ | |
| 628 | - "challenge_id": id, | |
| 629 | - "decision": "reject", | |
| 630 | - }) | |
| 631 | - .to_string(); | |
| 632 | - let resp = build_router(db) | |
| 633 | - .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) | |
| 634 | - .await | |
| 635 | - .unwrap(); | |
| 636 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 637 | - } | |
| 638 | -} |
D
crates/rustwarden-core/src/fediverse/error.rs
+0
-75
@@ -1,75 +0,0 @@
| 1 | -//! Federation-specific error type. | |
| 2 | - | |
| 3 | -use axum::http::StatusCode; | |
| 4 | -use axum::response::{IntoResponse, Response}; | |
| 5 | - | |
| 6 | -use crate::error::AppError; | |
| 7 | - | |
| 8 | -/// Errors produced by federation handlers (WebFinger / Actor / NodeInfo). | |
| 9 | -/// | |
| 10 | -/// Mapped to HTTP status codes via [`IntoResponse`]: | |
| 11 | -/// | |
| 12 | -/// | Variant | HTTP status | | |
| 13 | -/// |---|---| | |
| 14 | -/// | `UserNotFound` | 404 | | |
| 15 | -/// | `DomainMismatch` | 404 | | |
| 16 | -/// | `InvalidResource` | 400 | | |
| 17 | -/// | `NotAcceptable` | 406 | | |
| 18 | -/// | `App(_)` | inherited from [`AppError`] | | |
| 19 | -#[derive(Debug, thiserror::Error)] | |
| 20 | -pub enum FediverseError { | |
| 21 | - /// The requested local user does not exist or is disabled. | |
| 22 | - #[error("user not found")] | |
| 23 | - UserNotFound, | |
| 24 | - | |
| 25 | - /// The WebFinger `resource` parameter is malformed. | |
| 26 | - #[error("invalid WebFinger resource: {0}")] | |
| 27 | - InvalidResource(String), | |
| 28 | - | |
| 29 | - /// The WebFinger `resource` targets a domain that is not ours. | |
| 30 | - #[error("WebFinger domain mismatch")] | |
| 31 | - DomainMismatch, | |
| 32 | - | |
| 33 | - /// No representation exists matching the request `Accept` header. | |
| 34 | - #[error("acceptable representation not available")] | |
| 35 | - NotAcceptable, | |
| 36 | - | |
| 37 | - /// RFC 0012: signature missing or rejected by the verifier. | |
| 38 | - #[error("unauthorized: {0}")] | |
| 39 | - Unauthorized(String), | |
| 40 | - | |
| 41 | - /// RFC 0012: request body is invalid (bad JSON or missing required fields). | |
| 42 | - #[error("bad request: {0}")] | |
| 43 | - BadRequest(String), | |
| 44 | - | |
| 45 | - /// RFC 0012: activity type is not implemented by this server. | |
| 46 | - #[error("activity type not implemented: {0}")] | |
| 47 | - Unsupported(String), | |
| 48 | - | |
| 49 | - /// F8 (RFC 0027 / `experimental-fediverse`): the source domain is | |
| 50 | - /// not allowed by this server's trust mode (allowlist miss or | |
| 51 | - /// blocklist hit). 403 is the correct status code: the signature | |
| 52 | - /// itself is valid (would be 401 otherwise), policy refuses. | |
| 53 | - #[error("forbidden: {0}")] | |
| 54 | - Forbidden(String), | |
| 55 | - | |
| 56 | - /// Wraps a generic application error from [`AppError`]. | |
| 57 | - #[error(transparent)] | |
| 58 | - App(#[from] AppError), | |
| 59 | -} | |
| 60 | - | |
| 61 | -impl IntoResponse for FediverseError { | |
| 62 | - fn into_response(self) -> Response { | |
| 63 | - match self { | |
| 64 | - Self::UserNotFound | Self::DomainMismatch => StatusCode::NOT_FOUND.into_response(), | |
| 65 | - Self::InvalidResource(_) | Self::BadRequest(_) => { | |
| 66 | - StatusCode::BAD_REQUEST.into_response() | |
| 67 | - } | |
| 68 | - Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE.into_response(), | |
| 69 | - Self::Unauthorized(_) => StatusCode::UNAUTHORIZED.into_response(), | |
| 70 | - Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED.into_response(), | |
| 71 | - Self::Forbidden(_) => StatusCode::FORBIDDEN.into_response(), | |
| 72 | - Self::App(err) => err.into_response(), | |
| 73 | - } | |
| 74 | - } | |
| 75 | -} |
D
crates/rustwarden-core/src/fediverse/fetcher.rs
+0
-64
@@ -1,64 +0,0 @@
| 1 | -//! HTTP client abstraction used by [`RemoteIdentityService`](crate::services::remote_identity_service::RemoteIdentityService). | |
| 2 | -//! | |
| 3 | -//! The trait [`RemoteFetcher`] decouples the orchestration logic from the | |
| 4 | -//! actual reqwest / signed-HTTP implementation, which keeps unit tests | |
| 5 | -//! deterministic and offline. | |
| 6 | -//! | |
| 7 | -//! The real signed-HTTP implementation arrives with RFC 0010 (authorized | |
| 8 | -//! fetch + outbound worker). For now this module ships only the trait and | |
| 9 | -//! a no-op implementation that always errors, so consumers cannot | |
| 10 | -//! accidentally rely on a non-functional default. | |
| 11 | - | |
| 12 | -use async_trait::async_trait; | |
| 13 | -use url::Url; | |
| 14 | - | |
| 15 | -use super::error::FediverseError; | |
| 16 | -use super::types::{PersonJson, WebFingerResponse}; | |
| 17 | - | |
| 18 | -/// Abstraction over the HTTP I/O performed when resolving remote actors. | |
| 19 | -/// | |
| 20 | -/// Implementors are expected to handle TLS, redirects, and (eventually) | |
| 21 | -/// HTTP signatures. They MUST validate the `Content-Type` of WebFinger | |
| 22 | -/// (`application/jrd+json`) and actor responses (`application/activity+json` | |
| 23 | -/// or `application/ld+json`) before deserialising. | |
| 24 | -#[async_trait] | |
| 25 | -pub trait RemoteFetcher: Send + Sync { | |
| 26 | - /// Resolve a `@user@domain` handle via WebFinger. | |
| 27 | - /// | |
| 28 | - /// Returns [`FediverseError::UserNotFound`] if the remote server | |
| 29 | - /// responded with `404`. Returns [`FediverseError::App`] for any other | |
| 30 | - /// transport or deserialisation error. | |
| 31 | - async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError>; | |
| 32 | - | |
| 33 | - /// Fetch the JSON Person document at the given actor URL. | |
| 34 | - /// | |
| 35 | - /// Returns [`FediverseError::UserNotFound`] for `404`, | |
| 36 | - /// [`FediverseError::App`] for any other failure. | |
| 37 | - async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError>; | |
| 38 | -} | |
| 39 | - | |
| 40 | -/// Placeholder [`RemoteFetcher`] used until RFC 0010 ships the real | |
| 41 | -/// signed-HTTP implementation. Both methods always return | |
| 42 | -/// [`FediverseError::App`] with a configuration-style error. | |
| 43 | -/// | |
| 44 | -/// Useful as the default when consumers wire their own fetcher (e.g. | |
| 45 | -/// in tests, or in a deployment that has not yet enabled outbound HTTP). | |
| 46 | -#[derive(Debug, Default, Clone)] | |
| 47 | -pub struct UnconfiguredFetcher; | |
| 48 | - | |
| 49 | -#[async_trait] | |
| 50 | -impl RemoteFetcher for UnconfiguredFetcher { | |
| 51 | - async fn webfinger(&self, _handle: &str) -> Result<WebFingerResponse, FediverseError> { | |
| 52 | - Err(FediverseError::App(crate::error::AppError::Config( | |
| 53 | - "RemoteFetcher not configured — wire a real implementation via FediverseState" | |
| 54 | - .to_string(), | |
| 55 | - ))) | |
| 56 | - } | |
| 57 | - | |
| 58 | - async fn fetch_actor(&self, _actor_url: &Url) -> Result<PersonJson, FediverseError> { | |
| 59 | - Err(FediverseError::App(crate::error::AppError::Config( | |
| 60 | - "RemoteFetcher not configured — wire a real implementation via FediverseState" | |
| 61 | - .to_string(), | |
| 62 | - ))) | |
| 63 | - } | |
| 64 | -} |
D
crates/rustwarden-core/src/fediverse/http_signatures.rs
+0
-704
@@ -1,704 +0,0 @@
| 1 | -//! Mastodon-compatible HTTP signatures (draft-cavage-12) (RFC 0015). | |
| 2 | -//! | |
| 3 | -//! This module ships the production-ready cryptographic primitives | |
| 4 | -//! that plug into the [`SignatureVerifier`] / [`OutboundDelivery`] | |
| 5 | -//! traits. The current `SignatureVerifier` trait signature only carries | |
| 6 | -//! `signature_header` + `body` + `public_key_pem`; real HTTP-Sig | |
| 7 | -//! verification also needs the request method, path, and `host` / | |
| 8 | -//! `date` headers. Until a follow-up RFC widens the trait, callers | |
| 9 | -//! invoke [`parse_and_verify_cavage`] directly with the full request | |
| 10 | -//! context. | |
| 11 | -//! | |
| 12 | -//! The signing side is integrated end-to-end via [`HttpSignedDelivery`]. | |
| 13 | -//! | |
| 14 | -//! Gated by `experimental-fediverse`. | |
| 15 | - | |
| 16 | -use std::collections::BTreeMap; | |
| 17 | - | |
| 18 | -use base64::Engine; | |
| 19 | -use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey}; | |
| 20 | -use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; | |
| 21 | -use rsa::signature::{SignatureEncoding, Signer, Verifier}; | |
| 22 | -use rsa::{RsaPrivateKey, RsaPublicKey}; | |
| 23 | -use sha2::Sha256; | |
| 24 | - | |
| 25 | -use async_trait::async_trait; | |
| 26 | -use sea_orm::DatabaseConnection; | |
| 27 | - | |
| 28 | -use crate::error::AppError; | |
| 29 | -use crate::models::fediverse_outbound_queue; | |
| 30 | -use crate::services::actor_key_service::ActorKeyService; | |
| 31 | -use crate::services::fediverse_outbound_service::OutboundDelivery; | |
| 32 | -use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 33 | - | |
| 34 | -/// Canonical fields extracted from a draft-cavage `Signature` header. | |
| 35 | -#[derive(Debug, Clone, PartialEq, Eq)] | |
| 36 | -pub struct ParsedSignature { | |
| 37 | - /// Stable identifier of the signing key (e.g. | |
| 38 | - /// `https://a.tld/users/alice#main-key`). | |
| 39 | - pub key_id: String, | |
| 40 | - /// Algorithm. The verifier only accepts `rsa-sha256` for now. | |
| 41 | - pub algorithm: String, | |
| 42 | - /// Ordered list of signed headers (lowercased). | |
| 43 | - pub headers: Vec<String>, | |
| 44 | - /// Decoded signature bytes (raw, **not** base64-encoded). | |
| 45 | - pub signature: Vec<u8>, | |
| 46 | -} | |
| 47 | - | |
| 48 | -/// Parse a `Signature: keyId="…",algorithm="…",headers="…",signature="…"` | |
| 49 | -/// header into a [`ParsedSignature`]. | |
| 50 | -/// | |
| 51 | -/// Returns `Err(AppError::Validation(_))` when a required field is | |
| 52 | -/// missing or the signature is not valid base64. | |
| 53 | -pub fn parse_signature_header(raw: &str) -> Result<ParsedSignature, AppError> { | |
| 54 | - let mut map: BTreeMap<&str, String> = BTreeMap::new(); | |
| 55 | - for part in raw.split(',') { | |
| 56 | - let part = part.trim(); | |
| 57 | - let Some(eq) = part.find('=') else { continue }; | |
| 58 | - let (k, v) = part.split_at(eq); | |
| 59 | - let v = &v[1..]; // skip '=' | |
| 60 | - let v = v.trim(); | |
| 61 | - let unquoted = v.trim_start_matches('"').trim_end_matches('"'); | |
| 62 | - map.insert(k.trim(), unquoted.to_string()); | |
| 63 | - } | |
| 64 | - | |
| 65 | - let key_id = map | |
| 66 | - .remove("keyId") | |
| 67 | - .ok_or_else(|| AppError::Validation("Signature missing keyId".into()))?; | |
| 68 | - let algorithm = map | |
| 69 | - .remove("algorithm") | |
| 70 | - .ok_or_else(|| AppError::Validation("Signature missing algorithm".into()))?; | |
| 71 | - let headers_raw = map | |
| 72 | - .remove("headers") | |
| 73 | - .ok_or_else(|| AppError::Validation("Signature missing headers".into()))?; | |
| 74 | - let signature_b64 = map | |
| 75 | - .remove("signature") | |
| 76 | - .ok_or_else(|| AppError::Validation("Signature missing signature".into()))?; | |
| 77 | - | |
| 78 | - let headers = headers_raw | |
| 79 | - .split_whitespace() | |
| 80 | - .map(|h| h.to_ascii_lowercase()) | |
| 81 | - .collect(); | |
| 82 | - | |
| 83 | - let signature = base64::engine::general_purpose::STANDARD | |
| 84 | - .decode(&signature_b64) | |
| 85 | - .map_err(|e| { | |
| 86 | - AppError::Validation(format!("Signature base64 decode failed: {e}")) | |
| 87 | - })?; | |
| 88 | - | |
| 89 | - Ok(ParsedSignature { | |
| 90 | - key_id, | |
| 91 | - algorithm, | |
| 92 | - headers, | |
| 93 | - signature, | |
| 94 | - }) | |
| 95 | -} | |
| 96 | - | |
| 97 | -/// Build the **signature input string** that draft-cavage signs / verifies. | |
| 98 | -/// | |
| 99 | -/// `signed_headers` is the ordered list from the `Signature: headers="…"` | |
| 100 | -/// field. `request_target` is the lowercased HTTP method + space + | |
| 101 | -/// path-and-query (e.g. `"post /inbox"`). `lookup` returns the canonical | |
| 102 | -/// header value when called with a lowercased header name. | |
| 103 | -pub fn build_signature_input( | |
| 104 | - signed_headers: &[String], | |
| 105 | - request_target: &str, | |
| 106 | - lookup: impl Fn(&str) -> Option<String>, | |
| 107 | -) -> Result<String, AppError> { | |
| 108 | - let mut lines = Vec::with_capacity(signed_headers.len()); | |
| 109 | - for header in signed_headers { | |
| 110 | - let line = if header == "(request-target)" { | |
| 111 | - format!("(request-target): {request_target}") | |
| 112 | - } else { | |
| 113 | - let value = lookup(header).ok_or_else(|| { | |
| 114 | - AppError::Validation(format!( | |
| 115 | - "Signature header references missing field `{header}`", | |
| 116 | - )) | |
| 117 | - })?; | |
| 118 | - format!("{header}: {}", value.trim()) | |
| 119 | - }; | |
| 120 | - lines.push(line); | |
| 121 | - } | |
| 122 | - Ok(lines.join("\n")) | |
| 123 | -} | |
| 124 | - | |
| 125 | -/// Sign an arbitrary byte slice with an RSA-SHA256 PKCS#1 v1.5 | |
| 126 | -/// signature, returning the raw signature bytes. | |
| 127 | -pub fn rsa_sha256_sign(private_pem: &str, input: &[u8]) -> Result<Vec<u8>, AppError> { | |
| 128 | - let private_key = RsaPrivateKey::from_pkcs8_pem(private_pem) | |
| 129 | - .map_err(|e| AppError::Internal(format!("invalid PKCS#8 PEM: {e}")))?; | |
| 130 | - let signing_key = SigningKey::<Sha256>::new(private_key); | |
| 131 | - let signature = signing_key.sign(input); | |
| 132 | - Ok(signature.to_bytes().to_vec()) | |
| 133 | -} | |
| 134 | - | |
| 135 | -/// Verify an RSA-SHA256 PKCS#1 v1.5 signature against `input`. | |
| 136 | -pub fn rsa_sha256_verify( | |
| 137 | - public_pem: &str, | |
| 138 | - input: &[u8], | |
| 139 | - signature: &[u8], | |
| 140 | -) -> Result<(), AppError> { | |
| 141 | - let public_key = RsaPublicKey::from_public_key_pem(public_pem) | |
| 142 | - .map_err(|e| AppError::Internal(format!("invalid SPKI PEM: {e}")))?; | |
| 143 | - let verifying_key = VerifyingKey::<Sha256>::new(public_key); | |
| 144 | - let sig = Signature::try_from(signature) | |
| 145 | - .map_err(|e| AppError::Internal(format!("signature shape: {e}")))?; | |
| 146 | - verifying_key | |
| 147 | - .verify(input, &sig) | |
| 148 | - .map_err(|_| AppError::Internal("HTTP signature mismatch".into()))?; | |
| 149 | - Ok(()) | |
| 150 | -} | |
| 151 | - | |
| 152 | -/// Higher-level helper: parse + verify a draft-cavage Signature header | |
| 153 | -/// against the given request context. Use this in inbox handlers that | |
| 154 | -/// have access to the method, path, headers and body. | |
| 155 | -pub fn parse_and_verify_cavage( | |
| 156 | - signature_header: &str, | |
| 157 | - request_target: &str, | |
| 158 | - body: &[u8], | |
| 159 | - public_pem: &str, | |
| 160 | - headers: impl Fn(&str) -> Option<String>, | |
| 161 | -) -> Result<(), AppError> { | |
| 162 | - let parsed = parse_signature_header(signature_header)?; | |
| 163 | - if parsed.algorithm != "rsa-sha256" { | |
| 164 | - return Err(AppError::Validation(format!( | |
| 165 | - "unsupported algorithm: {}", | |
| 166 | - parsed.algorithm, | |
| 167 | - ))); | |
| 168 | - } | |
| 169 | - let input = build_signature_input(&parsed.headers, request_target, headers)?; | |
| 170 | - let _ = body; // body is hashed via the `digest` header by Mastodon when present. | |
| 171 | - rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature) | |
| 172 | -} | |
| 173 | - | |
| 174 | -/// Production-grade implementation of [`SignatureVerifier`] (RFC 0016 | |
| 175 | -/// + RFC 0017). | |
| 176 | -/// | |
| 177 | -/// Reads the `Signature` header, builds the canonical input string, | |
| 178 | -/// and validates the RSA-SHA256 signature. When `ctx.public_key_pem` | |
| 179 | -/// is empty: | |
| 180 | -/// | |
| 181 | -/// - if the verifier was built with [`Self::with_resolver`], it looks | |
| 182 | -/// up the actor in `remote_identities` (RFC 0003) and uses the | |
| 183 | -/// cached PEM ; | |
| 184 | -/// - otherwise it rejects with `Validation`. | |
| 185 | -pub struct HttpSigVerifier { | |
| 186 | - resolver_db: Option<DatabaseConnection>, | |
| 187 | -} | |
| 188 | - | |
| 189 | -impl HttpSigVerifier { | |
| 190 | - /// Build a verifier that does **not** auto-resolve the public | |
| 191 | - /// key. Callers must populate `ctx.public_key_pem` themselves. | |
| 192 | - pub fn new() -> Self { | |
| 193 | - Self { resolver_db: None } | |
| 194 | - } | |
| 195 | - | |
| 196 | - /// Build a verifier that auto-resolves the public key from the | |
| 197 | - /// `remote_identities` cache (RFC 0003) when `ctx.public_key_pem` | |
| 198 | - /// is empty (RFC 0017). | |
| 199 | - pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 200 | - Self { | |
| 201 | - resolver_db: Some(db), | |
| 202 | - } | |
| 203 | - } | |
| 204 | -} | |
| 205 | - | |
| 206 | -impl Default for HttpSigVerifier { | |
| 207 | - fn default() -> Self { | |
| 208 | - Self::new() | |
| 209 | - } | |
| 210 | -} | |
| 211 | - | |
| 212 | -#[async_trait] | |
| 213 | -impl SignatureVerifier for HttpSigVerifier { | |
| 214 | - async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 215 | - let signature_header = ctx | |
| 216 | - .headers | |
| 217 | - .get("signature") | |
| 218 | - .and_then(|v| v.to_str().ok()) | |
| 219 | - .ok_or_else(|| AppError::Validation("missing Signature header".into()))?; | |
| 220 | - | |
| 221 | - let resolved_pem; | |
| 222 | - let public_key_pem: &str = if !ctx.public_key_pem.is_empty() { | |
| 223 | - ctx.public_key_pem | |
| 224 | - } else if let Some(db) = self.resolver_db.as_ref() { | |
| 225 | - use crate::models::remote_identity; | |
| 226 | - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 227 | - let row = remote_identity::Entity::find() | |
| 228 | - .filter(remote_identity::Column::ApId.eq(ctx.actor_ap_id)) | |
| 229 | - .one(db) | |
| 230 | - .await? | |
| 231 | - .ok_or_else(|| { | |
| 232 | - AppError::Validation(format!( | |
| 233 | - "actor not in cache: {}", | |
| 234 | - ctx.actor_ap_id, | |
| 235 | - )) | |
| 236 | - })?; | |
| 237 | - resolved_pem = row.public_key_pem; | |
| 238 | - &resolved_pem | |
| 239 | - } else { | |
| 240 | - return Err(AppError::Validation( | |
| 241 | - "public key not resolved for actor".into(), | |
| 242 | - )); | |
| 243 | - }; | |
| 244 | - | |
| 245 | - let request_target = format!("{} {}", ctx.method, ctx.path); | |
| 246 | - parse_and_verify_cavage(signature_header, &request_target, ctx.body, public_key_pem, |h| { | |
| 247 | - ctx.headers.get(h).and_then(|v| v.to_str().ok().map(str::to_string)) | |
| 248 | - }) | |
| 249 | - } | |
| 250 | -} | |
| 251 | - | |
| 252 | -/// Production-grade implementation of [`OutboundDelivery`] (RFC 0016). | |
| 253 | -/// | |
| 254 | -/// Loads the local actor's RSA key (RFC 0005), signs the | |
| 255 | -/// `(request-target) host date` input string, and POSTs the activity | |
| 256 | -/// body to the remote inbox. | |
| 257 | -pub struct HttpSignedDelivery { | |
| 258 | - db: DatabaseConnection, | |
| 259 | - http: reqwest::Client, | |
| 260 | -} | |
| 261 | - | |
| 262 | -impl HttpSignedDelivery { | |
| 263 | - /// Build a delivery handler around an existing SeaORM connection. | |
| 264 | - /// Uses a default `reqwest::Client` (TLS via `native-tls`). | |
| 265 | - pub fn new(db: DatabaseConnection) -> Self { | |
| 266 | - Self { | |
| 267 | - db, | |
| 268 | - http: reqwest::Client::new(), | |
| 269 | - } | |
| 270 | - } | |
| 271 | -} | |
| 272 | - | |
| 273 | -#[async_trait] | |
| 274 | -impl OutboundDelivery for HttpSignedDelivery { | |
| 275 | - async fn deliver( | |
| 276 | - &self, | |
| 277 | - item: &fediverse_outbound_queue::Model, | |
| 278 | - ) -> Result<(), AppError> { | |
| 279 | - let actor_user_id = item.actor_user_id.ok_or_else(|| { | |
| 280 | - AppError::Config("HttpSignedDelivery requires actor_user_id".into()) | |
| 281 | - })?; | |
| 282 | - | |
| 283 | - let material = ActorKeyService::ensure_keys(&self.db, actor_user_id).await?; | |
| 284 | - | |
| 285 | - let url = url::Url::parse(&item.target_inbox).map_err(|e| { | |
| 286 | - AppError::Internal(format!("invalid target_inbox URL: {e}")) | |
| 287 | - })?; | |
| 288 | - let host = url | |
| 289 | - .host_str() | |
| 290 | - .ok_or_else(|| { | |
| 291 | - AppError::Internal("target_inbox URL has no host".to_string()) | |
| 292 | - })? | |
| 293 | - .to_string(); | |
| 294 | - let path = url.path().to_string(); | |
| 295 | - | |
| 296 | - let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 297 | - let signed_headers = vec![ | |
| 298 | - "(request-target)".to_string(), | |
| 299 | - "host".to_string(), | |
| 300 | - "date".to_string(), | |
| 301 | - ]; | |
| 302 | - let request_target = format!("post {path}"); | |
| 303 | - let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 304 | - "host" => Some(host.clone()), | |
| 305 | - "date" => Some(date.clone()), | |
| 306 | - _ => None, | |
| 307 | - })?; | |
| 308 | - | |
| 309 | - let signature_bytes = rsa_sha256_sign(&material.private_pem, input.as_bytes())?; | |
| 310 | - let signature_b64 = | |
| 311 | - base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 312 | - let signature_header = format!( | |
| 313 | - r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 314 | - item.target_inbox.trim_end_matches('/'), | |
| 315 | - ); | |
| 316 | - | |
| 317 | - let response = self | |
| 318 | - .http | |
| 319 | - .post(&item.target_inbox) | |
| 320 | - .header("Date", date) | |
| 321 | - .header("Host", host) | |
| 322 | - .header("Signature", signature_header) | |
| 323 | - .header("Content-Type", "application/activity+json") | |
| 324 | - .body(item.payload_json.clone()) | |
| 325 | - .send() | |
| 326 | - .await | |
| 327 | - .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 328 | - | |
| 329 | - let status = response.status(); | |
| 330 | - if status.is_success() { | |
| 331 | - Ok(()) | |
| 332 | - } else { | |
| 333 | - Err(AppError::Internal(format!( | |
| 334 | - "remote inbox responded {status}", | |
| 335 | - ))) | |
| 336 | - } | |
| 337 | - } | |
| 338 | -} | |
| 339 | - | |
| 340 | -#[cfg(test)] | |
| 341 | -mod tests { | |
| 342 | - use super::*; | |
| 343 | - use crate::services::actor_key_service::ActorKeyService; | |
| 344 | - | |
| 345 | - fn fresh_keypair() -> (String, String) { | |
| 346 | - // Generate once per test for honest end-to-end coverage. | |
| 347 | - let material = ActorKeyService::generate().expect("RSA gen"); | |
| 348 | - (material.public_pem, material.private_pem) | |
| 349 | - } | |
| 350 | - | |
| 351 | - fn b64(bytes: &[u8]) -> String { | |
| 352 | - base64::engine::general_purpose::STANDARD.encode(bytes) | |
| 353 | - } | |
| 354 | - | |
| 355 | - #[test] | |
| 356 | - fn parse_signature_header_extracts_keyid_algorithm_headers_signature() { | |
| 357 | - let raw = r#"keyId="https://a.tld/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="dGVzdA==""#; | |
| 358 | - let parsed = parse_signature_header(raw).expect("parse ok"); | |
| 359 | - assert_eq!(parsed.key_id, "https://a.tld/users/alice#main-key"); | |
| 360 | - assert_eq!(parsed.algorithm, "rsa-sha256"); | |
| 361 | - assert_eq!(parsed.headers, vec!["(request-target)", "host", "date"]); | |
| 362 | - assert_eq!(parsed.signature, b"test"); | |
| 363 | - } | |
| 364 | - | |
| 365 | - #[test] | |
| 366 | - fn parse_signature_header_rejects_missing_required_fields() { | |
| 367 | - let raw = r#"algorithm="rsa-sha256",headers="host","#; | |
| 368 | - let err = parse_signature_header(raw).expect_err("must fail"); | |
| 369 | - assert!(matches!(err, AppError::Validation(_))); | |
| 370 | - } | |
| 371 | - | |
| 372 | - #[test] | |
| 373 | - fn build_signature_input_uses_lowercase_header_names() { | |
| 374 | - let signed = vec!["host".to_string(), "date".to_string()]; | |
| 375 | - let input = build_signature_input(&signed, "post /inbox", |h| match h { | |
| 376 | - "host" => Some("b.tld".into()), | |
| 377 | - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 378 | - _ => None, | |
| 379 | - }) | |
| 380 | - .unwrap(); | |
| 381 | - assert!(input.contains("host: b.tld")); | |
| 382 | - assert!(input.contains("date: Mon, 10 May 2026 12:00:00 GMT")); | |
| 383 | - } | |
| 384 | - | |
| 385 | - #[test] | |
| 386 | - fn build_signature_input_handles_request_target() { | |
| 387 | - let signed = vec!["(request-target)".to_string()]; | |
| 388 | - let input = build_signature_input(&signed, "post /inbox", |_| None).unwrap(); | |
| 389 | - assert_eq!(input, "(request-target): post /inbox"); | |
| 390 | - } | |
| 391 | - | |
| 392 | - #[test] | |
| 393 | - fn build_signature_input_errors_on_missing_field() { | |
| 394 | - let signed = vec!["digest".to_string()]; | |
| 395 | - let err = build_signature_input(&signed, "post /inbox", |_| None).expect_err(""); | |
| 396 | - assert!(matches!(err, AppError::Validation(_))); | |
| 397 | - } | |
| 398 | - | |
| 399 | - #[test] | |
| 400 | - fn sign_then_verify_round_trip() { | |
| 401 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 402 | - let input = b"(request-target): post /inbox\nhost: b.tld\ndate: x"; | |
| 403 | - let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 404 | - rsa_sha256_verify(&pub_pem, input, &sig).expect("verify ok"); | |
| 405 | - } | |
| 406 | - | |
| 407 | - #[test] | |
| 408 | - fn verify_rejects_tampered_signature() { | |
| 409 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 410 | - let input = b"hello world"; | |
| 411 | - let mut sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 412 | - sig[0] ^= 0xff; | |
| 413 | - let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err("must reject"); | |
| 414 | - assert!(matches!(err, AppError::Internal(_))); | |
| 415 | - } | |
| 416 | - | |
| 417 | - #[test] | |
| 418 | - fn verify_rejects_wrong_public_key() { | |
| 419 | - let (_other_pub, priv_pem) = fresh_keypair(); | |
| 420 | - let (pub_pem, _) = fresh_keypair(); | |
| 421 | - let input = b"hello"; | |
| 422 | - let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 423 | - let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err(""); | |
| 424 | - assert!(matches!(err, AppError::Internal(_))); | |
| 425 | - } | |
| 426 | - | |
| 427 | - #[test] | |
| 428 | - fn parse_and_verify_cavage_accepts_well_formed_signature() { | |
| 429 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 430 | - let signed_headers = vec![ | |
| 431 | - "(request-target)".to_string(), | |
| 432 | - "host".to_string(), | |
| 433 | - "date".to_string(), | |
| 434 | - ]; | |
| 435 | - let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 436 | - "host" => Some("b.tld".into()), | |
| 437 | - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 438 | - _ => None, | |
| 439 | - }) | |
| 440 | - .unwrap(); | |
| 441 | - let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 442 | - let header = format!( | |
| 443 | - r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 444 | - b64(&sig_bytes), | |
| 445 | - ); | |
| 446 | - parse_and_verify_cavage(&header, "post /inbox", b"", &pub_pem, |h| match h { | |
| 447 | - "host" => Some("b.tld".into()), | |
| 448 | - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 449 | - _ => None, | |
| 450 | - }) | |
| 451 | - .expect("verify ok"); | |
| 452 | - } | |
| 453 | - | |
| 454 | - #[test] | |
| 455 | - fn parse_and_verify_cavage_rejects_unsupported_algorithm() { | |
| 456 | - let header = r#"keyId="x",algorithm="hs2019",headers="host",signature="dGVzdA==""#; | |
| 457 | - let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| None) | |
| 458 | - .expect_err(""); | |
| 459 | - assert!(matches!(err, AppError::Validation(_))); | |
| 460 | - } | |
| 461 | - | |
| 462 | - #[test] | |
| 463 | - fn parse_and_verify_cavage_rejects_invalid_base64() { | |
| 464 | - let header = r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="???""#; | |
| 465 | - let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| Some("h".into())) | |
| 466 | - .expect_err(""); | |
| 467 | - assert!(matches!(err, AppError::Validation(_))); | |
| 468 | - } | |
| 469 | - | |
| 470 | - // ─── RFC 0016 — HttpSigVerifier + HttpSignedDelivery ─────────── | |
| 471 | - | |
| 472 | - #[tokio::test] | |
| 473 | - async fn http_sig_verifier_rejects_when_signature_header_missing() { | |
| 474 | - use axum::http::HeaderMap; | |
| 475 | - let headers = HeaderMap::new(); | |
| 476 | - let ctx = VerifyCtx { | |
| 477 | - actor_ap_id: "x", | |
| 478 | - method: "post", | |
| 479 | - path: "/inbox", | |
| 480 | - headers: &headers, | |
| 481 | - body: b"", | |
| 482 | - public_key_pem: "anything", | |
| 483 | - }; | |
| 484 | - let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 485 | - assert!(matches!(err, AppError::Validation(_))); | |
| 486 | - } | |
| 487 | - | |
| 488 | - #[tokio::test] | |
| 489 | - async fn http_sig_verifier_rejects_when_public_key_pem_empty() { | |
| 490 | - use axum::http::HeaderMap; | |
| 491 | - let mut headers = HeaderMap::new(); | |
| 492 | - headers.insert("signature", "keyId=\"x\"".parse().unwrap()); | |
| 493 | - let ctx = VerifyCtx { | |
| 494 | - actor_ap_id: "x", | |
| 495 | - method: "post", | |
| 496 | - path: "/inbox", | |
| 497 | - headers: &headers, | |
| 498 | - body: b"", | |
| 499 | - public_key_pem: "", | |
| 500 | - }; | |
| 501 | - let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 502 | - assert!(matches!(err, AppError::Validation(_))); | |
| 503 | - } | |
| 504 | - | |
| 505 | - #[tokio::test] | |
| 506 | - async fn http_sig_verifier_accepts_valid_signature() { | |
| 507 | - use axum::http::{HeaderMap, HeaderValue}; | |
| 508 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 509 | - let signed_headers = vec![ | |
| 510 | - "(request-target)".to_string(), | |
| 511 | - "host".to_string(), | |
| 512 | - "date".to_string(), | |
| 513 | - ]; | |
| 514 | - let input = | |
| 515 | - build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 516 | - "host" => Some("b.tld".into()), | |
| 517 | - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 518 | - _ => None, | |
| 519 | - }) | |
| 520 | - .unwrap(); | |
| 521 | - let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 522 | - let header_value = format!( | |
| 523 | - r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 524 | - b64(&sig_bytes), | |
| 525 | - ); | |
| 526 | - let mut headers = HeaderMap::new(); | |
| 527 | - headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 528 | - headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 529 | - headers.insert( | |
| 530 | - "date", | |
| 531 | - HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 532 | - ); | |
| 533 | - let body = b""; | |
| 534 | - let ctx = VerifyCtx { | |
| 535 | - actor_ap_id: "x", | |
| 536 | - method: "post", | |
| 537 | - path: "/inbox", | |
| 538 | - headers: &headers, | |
| 539 | - body, | |
| 540 | - public_key_pem: &pub_pem, | |
| 541 | - }; | |
| 542 | - HttpSigVerifier::new() | |
| 543 | - .verify(&ctx) | |
| 544 | - .await | |
| 545 | - .expect("valid signature must pass"); | |
| 546 | - } | |
| 547 | - | |
| 548 | - #[tokio::test] | |
| 549 | - async fn http_sig_verifier_resolves_pem_from_remote_identities_when_empty() { | |
| 550 | - use axum::http::{HeaderMap, HeaderValue}; | |
| 551 | - use chrono::Utc; | |
| 552 | - use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 553 | - use uuid::Uuid; | |
| 554 | - | |
| 555 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 556 | - let signed_headers = vec![ | |
| 557 | - "(request-target)".to_string(), | |
| 558 | - "host".to_string(), | |
| 559 | - "date".to_string(), | |
| 560 | - ]; | |
| 561 | - let input = | |
| 562 | - build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 563 | - "host" => Some("b.tld".into()), | |
| 564 | - "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 565 | - _ => None, | |
| 566 | - }) | |
| 567 | - .unwrap(); | |
| 568 | - let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 569 | - let header_value = format!( | |
| 570 | - r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 571 | - b64(&sig_bytes), | |
| 572 | - ); | |
| 573 | - let mut headers = HeaderMap::new(); | |
| 574 | - headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 575 | - headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 576 | - headers.insert( | |
| 577 | - "date", | |
| 578 | - HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 579 | - ); | |
| 580 | - | |
| 581 | - let now = Utc::now(); | |
| 582 | - let cached = crate::models::remote_identity::Model { | |
| 583 | - id: Uuid::new_v4(), | |
| 584 | - ap_id: "https://a.tld/users/alice".into(), | |
| 585 | - username: "alice".into(), | |
| 586 | - domain: "a.tld".into(), | |
| 587 | - display_name: None, | |
| 588 | - summary_html: None, | |
| 589 | - inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 590 | - shared_inbox_url: None, | |
| 591 | - public_key_pem: pub_pem, | |
| 592 | - icon_url: None, | |
| 593 | - actor_type: "Person".into(), | |
| 594 | - raw_json: None, | |
| 595 | - last_fetched_at: now, | |
| 596 | - disabled_at: None, | |
| 597 | - created_at: now, | |
| 598 | - updated_at: now, | |
| 599 | - }; | |
| 600 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 601 | - .append_query_results([vec![cached]]) | |
| 602 | - .into_connection(); | |
| 603 | - | |
| 604 | - let ctx = VerifyCtx { | |
| 605 | - actor_ap_id: "https://a.tld/users/alice", | |
| 606 | - method: "post", | |
| 607 | - path: "/inbox", | |
| 608 | - headers: &headers, | |
| 609 | - body: b"", | |
| 610 | - public_key_pem: "", // empty → triggers resolver path | |
| 611 | - }; | |
| 612 | - HttpSigVerifier::with_resolver(db) | |
| 613 | - .verify(&ctx) | |
| 614 | - .await | |
| 615 | - .expect("resolver path must accept valid signature"); | |
| 616 | - } | |
| 617 | - | |
| 618 | - #[tokio::test] | |
| 619 | - async fn http_sig_verifier_rejects_when_actor_not_in_cache() { | |
| 620 | - use axum::http::{HeaderMap, HeaderValue}; | |
| 621 | - use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 622 | - | |
| 623 | - let mut headers = HeaderMap::new(); | |
| 624 | - headers.insert( | |
| 625 | - "signature", | |
| 626 | - HeaderValue::from_static(r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="dGVzdA==""#), | |
| 627 | - ); | |
| 628 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 629 | - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 630 | - .into_connection(); | |
| 631 | - | |
| 632 | - let ctx = VerifyCtx { | |
| 633 | - actor_ap_id: "https://ghost.tld/users/nobody", | |
| 634 | - method: "post", | |
| 635 | - path: "/inbox", | |
| 636 | - headers: &headers, | |
| 637 | - body: b"", | |
| 638 | - public_key_pem: "", | |
| 639 | - }; | |
| 640 | - let err = HttpSigVerifier::with_resolver(db) | |
| 641 | - .verify(&ctx) | |
| 642 | - .await | |
| 643 | - .expect_err("must reject unknown actor"); | |
| 644 | - assert!(matches!(err, AppError::Validation(_))); | |
| 645 | - } | |
| 646 | - | |
| 647 | - #[tokio::test] | |
| 648 | - async fn http_signed_delivery_rejects_when_actor_user_id_none() { | |
| 649 | - use crate::models::fediverse_outbound_queue::Model; | |
| 650 | - use chrono::Utc; | |
| 651 | - use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 652 | - use uuid::Uuid; | |
| 653 | - let db: DatabaseConnection = | |
| 654 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 655 | - let now = Utc::now(); | |
| 656 | - let item = Model { | |
| 657 | - id: Uuid::new_v4(), | |
| 658 | - activity_id: "act".into(), | |
| 659 | - payload_json: "{}".into(), | |
| 660 | - target_inbox: "https://b.tld/inbox".into(), | |
| 661 | - target_domain: "b.tld".into(), | |
| 662 | - status: "pending".into(), | |
| 663 | - attempts: 0, | |
| 664 | - max_attempts: 8, | |
| 665 | - last_error: None, | |
| 666 | - next_retry_at: None, | |
| 667 | - created_at: now, | |
| 668 | - delivered_at: None, | |
| 669 | - actor_user_id: None, | |
| 670 | - }; | |
| 671 | - let delivery = HttpSignedDelivery::new(db); | |
| 672 | - let err = delivery.deliver(&item).await.expect_err("must reject"); | |
| 673 | - assert!(matches!(err, AppError::Config(_))); | |
| 674 | - } | |
| 675 | - | |
| 676 | - #[test] | |
| 677 | - fn parse_and_verify_cavage_rejects_tampered_input() { | |
| 678 | - let (pub_pem, priv_pem) = fresh_keypair(); | |
| 679 | - let signed_headers = vec!["host".to_string()]; | |
| 680 | - let input = build_signature_input(&signed_headers, "post /x", |h| { | |
| 681 | - if h == "host" { | |
| 682 | - Some("b.tld".into()) | |
| 683 | - } else { | |
| 684 | - None | |
| 685 | - } | |
| 686 | - }) | |
| 687 | - .unwrap(); | |
| 688 | - let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 689 | - let header = format!( | |
| 690 | - r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="{}""#, | |
| 691 | - b64(&sig_bytes), | |
| 692 | - ); | |
| 693 | - // Verify against a different host value. | |
| 694 | - let err = parse_and_verify_cavage(&header, "post /x", b"", &pub_pem, |h| { | |
| 695 | - if h == "host" { | |
| 696 | - Some("evil.tld".into()) | |
| 697 | - } else { | |
| 698 | - None | |
| 699 | - } | |
| 700 | - }) | |
| 701 | - .expect_err("must reject tampered host"); | |
| 702 | - assert!(matches!(err, AppError::Internal(_))); | |
| 703 | - } | |
| 704 | -} |
D
crates/rustwarden-core/src/fediverse/inbox.rs
+0
-439
@@ -1,439 +0,0 @@
| 1 | -//! `POST /inbox` handler — receives ActivityPub lifecycle activities | |
| 2 | -//! and dispatches them to [`crate::services::fediverse_signal_service::FediverseSignalService`] | |
| 3 | -//! (RFC 0012). | |
| 4 | -//! | |
| 5 | -//! The route reads the `Signature` HTTP header, delegates verification | |
| 6 | -//! to the configured [`SignatureVerifier`], then routes by activity | |
| 7 | -//! `type`. Only `Update`, `Delete`, and `Revoke` are supported in this | |
| 8 | -//! slice; the rest get `501 Not Implemented`. | |
| 9 | -//! | |
| 10 | -//! Gated by `experimental-fediverse`. | |
| 11 | - | |
| 12 | -use std::sync::Arc; | |
| 13 | - | |
| 14 | -use axum::{Extension, body::Bytes, http::HeaderMap, http::StatusCode}; | |
| 15 | -use sea_orm::DatabaseConnection; | |
| 16 | -use serde::Deserialize; | |
| 17 | -use uuid::Uuid; | |
| 18 | - | |
| 19 | -use super::error::FediverseError; | |
| 20 | -use crate::services::fediverse_signal_service::{ | |
| 21 | - FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, | |
| 22 | -}; | |
| 23 | - | |
| 24 | -/// Per-router state injected into the inbox handler. | |
| 25 | -/// | |
| 26 | -/// Cheaply clonable (the verifier is `Arc`-ed). The application | |
| 27 | -/// instantiates this once via `RustwardenBuilder::with_fediverse`. | |
| 28 | -#[derive(Clone)] | |
| 29 | -pub struct InboxState { | |
| 30 | - /// Pluggable signature verifier. Defaults to `AlwaysAcceptVerifier` | |
| 31 | - /// when the consumer does not call | |
| 32 | - /// `RustwardenBuilder::with_signature_verifier`. | |
| 33 | - pub verifier: Arc<dyn SignatureVerifier>, | |
| 34 | -} | |
| 35 | - | |
| 36 | -/// Minimal projection of the JSON-LD payload. | |
| 37 | -/// | |
| 38 | -/// We only deserialise the fields the dispatcher needs; the full body | |
| 39 | -/// is forwarded as `serde_json::Value` to the service for audit / | |
| 40 | -/// re-processing. | |
| 41 | -#[derive(Debug, Deserialize)] | |
| 42 | -struct InboxActivity { | |
| 43 | - #[serde(rename = "type")] | |
| 44 | - kind: String, | |
| 45 | - actor: Option<String>, | |
| 46 | - object: Option<serde_json::Value>, | |
| 47 | -} | |
| 48 | - | |
| 49 | -/// `POST /inbox` axum handler. | |
| 50 | -pub async fn handle( | |
| 51 | - Extension(state): Extension<InboxState>, | |
| 52 | - Extension(db): Extension<DatabaseConnection>, | |
| 53 | - headers: HeaderMap, | |
| 54 | - body: Bytes, | |
| 55 | -) -> Result<StatusCode, FediverseError> { | |
| 56 | - // 1. Signature verification (delegated). | |
| 57 | - let signature_header = headers | |
| 58 | - .get("signature") | |
| 59 | - .and_then(|v| v.to_str().ok()) | |
| 60 | - .ok_or_else(|| { | |
| 61 | - FediverseError::Unauthorized("missing Signature header".to_string()) | |
| 62 | - })?; | |
| 63 | - | |
| 64 | - let activity: InboxActivity = serde_json::from_slice(&body) | |
| 65 | - .map_err(|e| FediverseError::BadRequest(format!("invalid JSON: {e}")))?; | |
| 66 | - | |
| 67 | - let actor_ap_id = activity.actor.as_deref().unwrap_or(""); | |
| 68 | - // Touch `signature_header` to keep the early validation in place | |
| 69 | - // (its absence already errors above) — the value is also reachable | |
| 70 | - // via `headers["signature"]` for impls. | |
| 71 | - let _ = signature_header; | |
| 72 | - | |
| 73 | - // F8 / RFC 0027 — trust policy gate. Reject before any verifier work | |
| 74 | - // or downstream effect when the source domain isn't allowed. | |
| 75 | - let source_domain = url::Url::parse(actor_ap_id) | |
| 76 | - .ok() | |
| 77 | - .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 78 | - .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 79 | - crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 80 | - .assert_domain_allowed(&source_domain) | |
| 81 | - .await | |
| 82 | - .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 83 | - | |
| 84 | - state | |
| 85 | - .verifier | |
| 86 | - .verify(&VerifyCtx { | |
| 87 | - actor_ap_id, | |
| 88 | - method: "post", | |
| 89 | - path: "/inbox", | |
| 90 | - headers: &headers, | |
| 91 | - body: &body, | |
| 92 | - public_key_pem: "", | |
| 93 | - }) | |
| 94 | - .await | |
| 95 | - .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; | |
| 96 | - | |
| 97 | - // 2. Dispatch by activity kind. | |
| 98 | - match activity.kind.as_str() { | |
| 99 | - "Update" => dispatch_update(&db, &activity).await?, | |
| 100 | - "Delete" => dispatch_delete(&db, &activity).await?, | |
| 101 | - "Revoke" => dispatch_revoke(&db, &activity).await?, | |
| 102 | - other => { | |
| 103 | - return Err(FediverseError::Unsupported(other.to_string())); | |
| 104 | - } | |
| 105 | - } | |
| 106 | - | |
| 107 | - Ok(StatusCode::ACCEPTED) | |
| 108 | -} | |
| 109 | - | |
| 110 | -async fn dispatch_update( | |
| 111 | - db: &DatabaseConnection, | |
| 112 | - activity: &InboxActivity, | |
| 113 | -) -> Result<(), FediverseError> { | |
| 114 | - let object = activity | |
| 115 | - .object | |
| 116 | - .as_ref() | |
| 117 | - .ok_or_else(|| FediverseError::BadRequest("Update missing `object`".into()))?; | |
| 118 | - | |
| 119 | - let ap_id = object | |
| 120 | - .get("id") | |
| 121 | - .and_then(|v| v.as_str()) | |
| 122 | - .ok_or_else(|| FediverseError::BadRequest("Update.object missing `id`".into()))? | |
| 123 | - .to_string(); | |
| 124 | - | |
| 125 | - let display_name = object | |
| 126 | - .get("name") | |
| 127 | - .and_then(|v| v.as_str()) | |
| 128 | - .map(str::to_string); | |
| 129 | - let summary_html = object | |
| 130 | - .get("summary") | |
| 131 | - .and_then(|v| v.as_str()) | |
| 132 | - .map(str::to_string); | |
| 133 | - let icon_url = object | |
| 134 | - .get("icon") | |
| 135 | - .and_then(|v| v.get("url")) | |
| 136 | - .and_then(|v| v.as_str()) | |
| 137 | - .map(str::to_string); | |
| 138 | - let public_key_pem = object | |
| 139 | - .get("publicKey") | |
| 140 | - .and_then(|v| v.get("publicKeyPem")) | |
| 141 | - .and_then(|v| v.as_str()) | |
| 142 | - .map(str::to_string); | |
| 143 | - | |
| 144 | - FediverseSignalService::apply_update_actor( | |
| 145 | - db, | |
| 146 | - UpdatedActor { | |
| 147 | - ap_id, | |
| 148 | - display_name, | |
| 149 | - summary_html, | |
| 150 | - icon_url, | |
| 151 | - public_key_pem, | |
| 152 | - raw_json: Some(object.clone()), | |
| 153 | - }, | |
| 154 | - ) | |
| 155 | - .await?; | |
| 156 | - Ok(()) | |
| 157 | -} | |
| 158 | - | |
| 159 | -async fn dispatch_delete( | |
| 160 | - db: &DatabaseConnection, | |
| 161 | - activity: &InboxActivity, | |
| 162 | -) -> Result<(), FediverseError> { | |
| 163 | - // AP `Delete{Actor}` carries the actor URL either as a string in | |
| 164 | - // `object` or as `{ "id": "...", "type": "Person" }`. | |
| 165 | - let ap_id = activity | |
| 166 | - .object | |
| 167 | - .as_ref() | |
| 168 | - .and_then(|o| { | |
| 169 | - o.as_str() | |
| 170 | - .map(str::to_string) | |
| 171 | - .or_else(|| o.get("id").and_then(|v| v.as_str()).map(str::to_string)) | |
| 172 | - }) | |
| 173 | - .ok_or_else(|| FediverseError::BadRequest("Delete missing actor reference".into()))?; | |
| 174 | - | |
| 175 | - FediverseSignalService::apply_delete_actor(db, &ap_id).await?; | |
| 176 | - Ok(()) | |
| 177 | -} | |
| 178 | - | |
| 179 | -async fn dispatch_revoke( | |
| 180 | - db: &DatabaseConnection, | |
| 181 | - activity: &InboxActivity, | |
| 182 | -) -> Result<(), FediverseError> { | |
| 183 | - let object = activity | |
| 184 | - .object | |
| 185 | - .as_ref() | |
| 186 | - .ok_or_else(|| FediverseError::BadRequest("Revoke missing `object`".into()))?; | |
| 187 | - | |
| 188 | - let team_id = object | |
| 189 | - .get("team") | |
| 190 | - .and_then(|v| v.as_str()) | |
| 191 | - .and_then(|s| s.rsplit('/').next()) | |
| 192 | - .and_then(|s| Uuid::parse_str(s).ok()) | |
| 193 | - .ok_or_else(|| FediverseError::BadRequest("Revoke.object.team missing/malformed".into()))?; | |
| 194 | - | |
| 195 | - let remote_identity_id = object | |
| 196 | - .get("subject") | |
| 197 | - .and_then(|v| v.as_str()) | |
| 198 | - .and_then(|s| Uuid::parse_str(s).ok()) | |
| 199 | - .ok_or_else(|| FediverseError::BadRequest("Revoke.object.subject missing/malformed".into()))?; | |
| 200 | - | |
| 201 | - FediverseSignalService::apply_revoke_membership(db, team_id, remote_identity_id).await?; | |
| 202 | - Ok(()) | |
| 203 | -} | |
| 204 | - | |
| 205 | -#[cfg(test)] | |
| 206 | -mod tests { | |
| 207 | - use super::*; | |
| 208 | - use crate::error::AppError; | |
| 209 | - use async_trait::async_trait; | |
| 210 | - use axum::{Router, body::Body, http::Request, routing::post}; | |
| 211 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 212 | - use tower::ServiceExt; | |
| 213 | - | |
| 214 | - fn make_router(verifier: Arc<dyn SignatureVerifier>, db: DatabaseConnection) -> Router { | |
| 215 | - Router::new() | |
| 216 | - .route("/inbox", post(handle)) | |
| 217 | - .layer(Extension(InboxState { verifier })) | |
| 218 | - .layer(Extension(db)) | |
| 219 | - } | |
| 220 | - | |
| 221 | - fn signed_request(body: &str) -> Request<Body> { | |
| 222 | - Request::builder() | |
| 223 | - .uri("/inbox") | |
| 224 | - .method("POST") | |
| 225 | - .header("signature", "keyId=\"test\",signature=\"...\"") | |
| 226 | - .header("content-type", "application/activity+json") | |
| 227 | - .body(Body::from(body.to_string())) | |
| 228 | - .unwrap() | |
| 229 | - } | |
| 230 | - | |
| 231 | - struct AlwaysAccept; | |
| 232 | - | |
| 233 | - #[async_trait] | |
| 234 | - impl SignatureVerifier for AlwaysAccept { | |
| 235 | - async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 236 | - Ok(()) | |
| 237 | - } | |
| 238 | - } | |
| 239 | - | |
| 240 | - struct AlwaysReject; | |
| 241 | - | |
| 242 | - #[async_trait] | |
| 243 | - impl SignatureVerifier for AlwaysReject { | |
| 244 | - async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 245 | - Err(AppError::Internal("nope".into())) | |
| 246 | - } | |
| 247 | - } | |
| 248 | - | |
| 249 | - /// F8 enforcement helper: seed three mock results so that | |
| 250 | - /// `FediverseTrustService::assert_domain_allowed` answers Ok with | |
| 251 | - /// no real DB. Mode is `open_with_blocklist` + empty blocklist, the | |
| 252 | - /// cheapest permissive shape for unit tests. | |
| 253 | - fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 254 | - use crate::models::{app_settings, fediverse_blocked_domain}; | |
| 255 | - let now = chrono::Utc::now(); | |
| 256 | - mock.append_query_results([vec![app_settings::Model { | |
| 257 | - id: Uuid::new_v4(), | |
| 258 | - key: "fediverse_enabled".to_string(), | |
| 259 | - value: "true".to_string(), | |
| 260 | - description: None, | |
| 261 | - updated_by: None, | |
| 262 | - updated_at: now, | |
| 263 | - }]]) | |
| 264 | - .append_query_results([vec![app_settings::Model { | |
| 265 | - id: Uuid::new_v4(), | |
| 266 | - key: "fediverse_trust_mode".to_string(), | |
| 267 | - value: "open_with_blocklist".to_string(), | |
| 268 | - description: None, | |
| 269 | - updated_by: None, | |
| 270 | - updated_at: now, | |
| 271 | - }]]) | |
| 272 | - .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 273 | - } | |
| 274 | - | |
| 275 | - fn db_for_update() -> DatabaseConnection { | |
| 276 | - let now = chrono::Utc::now(); | |
| 277 | - let row = crate::models::remote_identity::Model { | |
| 278 | - id: Uuid::new_v4(), | |
| 279 | - ap_id: "https://a.tld/users/alice".into(), | |
| 280 | - username: "alice".into(), | |
| 281 | - domain: "a.tld".into(), | |
| 282 | - display_name: None, | |
| 283 | - summary_html: None, | |
| 284 | - inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 285 | - shared_inbox_url: None, | |
| 286 | - public_key_pem: String::new(), | |
| 287 | - icon_url: None, | |
| 288 | - actor_type: "Person".into(), | |
| 289 | - raw_json: None, | |
| 290 | - last_fetched_at: now, | |
| 291 | - disabled_at: None, | |
| 292 | - created_at: now, | |
| 293 | - updated_at: now, | |
| 294 | - }; | |
| 295 | - let mut after = row.clone(); | |
| 296 | - after.display_name = Some("Alice".into()); | |
| 297 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 298 | - .append_query_results([vec![row]]) | |
| 299 | - .append_query_results([vec![after]]) | |
| 300 | - .append_exec_results([MockExecResult { | |
| 301 | - last_insert_id: 0, | |
| 302 | - rows_affected: 1, | |
| 303 | - }]) | |
| 304 | - .into_connection() | |
| 305 | - } | |
| 306 | - | |
| 307 | - fn db_for_delete_noop() -> DatabaseConnection { | |
| 308 | - // SELECT returns empty → DeleteOutcome::default(), no UPDATE. | |
| 309 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 310 | - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 311 | - .into_connection() | |
| 312 | - } | |
| 313 | - | |
| 314 | - fn db_for_revoke_noop() -> DatabaseConnection { | |
| 315 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 316 | - .append_exec_results([MockExecResult { | |
| 317 | - last_insert_id: 0, | |
| 318 | - rows_affected: 0, | |
| 319 | - }]) | |
| 320 | - .into_connection() | |
| 321 | - } | |
| 322 | - | |
| 323 | - #[tokio::test] | |
| 324 | - async fn inbox_returns_202_for_valid_update_actor() { | |
| 325 | - let body = serde_json::json!({ | |
| 326 | - "type": "Update", | |
| 327 | - "actor": "https://a.tld/users/alice", | |
| 328 | - "object": { | |
| 329 | - "id": "https://a.tld/users/alice", | |
| 330 | - "type": "Person", | |
| 331 | - "name": "Alice", | |
| 332 | - } | |
| 333 | - }) | |
| 334 | - .to_string(); | |
| 335 | - let router = make_router(Arc::new(AlwaysAccept), db_for_update()); | |
| 336 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 337 | - assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 338 | - } | |
| 339 | - | |
| 340 | - #[tokio::test] | |
| 341 | - async fn inbox_returns_202_for_valid_delete_actor() { | |
| 342 | - let body = serde_json::json!({ | |
| 343 | - "type": "Delete", | |
| 344 | - "actor": "https://a.tld/users/alice", | |
| 345 | - "object": "https://a.tld/users/alice" | |
| 346 | - }) | |
| 347 | - .to_string(); | |
| 348 | - let router = make_router(Arc::new(AlwaysAccept), db_for_delete_noop()); | |
| 349 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 350 | - assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 351 | - } | |
| 352 | - | |
| 353 | - #[tokio::test] | |
| 354 | - async fn inbox_returns_202_for_valid_revoke_membership() { | |
| 355 | - let team_id = Uuid::new_v4(); | |
| 356 | - let remote_id = Uuid::new_v4(); | |
| 357 | - let body = serde_json::json!({ | |
| 358 | - "type": "Revoke", | |
| 359 | - "actor": "https://a.tld/users/alice", | |
| 360 | - "object": { | |
| 361 | - "type": "TeamMembership", | |
| 362 | - "team": format!("https://b.tld/teams/{team_id}"), | |
| 363 | - "subject": remote_id.to_string(), | |
| 364 | - } | |
| 365 | - }) | |
| 366 | - .to_string(); | |
| 367 | - let router = make_router(Arc::new(AlwaysAccept), db_for_revoke_noop()); | |
| 368 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 369 | - assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 370 | - } | |
| 371 | - | |
| 372 | - #[tokio::test] | |
| 373 | - async fn inbox_returns_401_when_signature_header_missing() { | |
| 374 | - let body = "{\"type\":\"Update\"}"; | |
| 375 | - let req = Request::builder() | |
| 376 | - .uri("/inbox") | |
| 377 | - .method("POST") | |
| 378 | - .body(Body::from(body)) | |
| 379 | - .unwrap(); | |
| 380 | - let router = make_router( | |
| 381 | - Arc::new(AlwaysAccept), | |
| 382 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 383 | - ); | |
| 384 | - let resp = router.oneshot(req).await.unwrap(); | |
| 385 | - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 386 | - } | |
| 387 | - | |
| 388 | - #[tokio::test] | |
| 389 | - async fn inbox_returns_401_when_verifier_rejects() { | |
| 390 | - let body = serde_json::json!({ | |
| 391 | - "type": "Update", "actor": "https://a.tld/users/alice" | |
| 392 | - }) | |
| 393 | - .to_string(); | |
| 394 | - let router = make_router( | |
| 395 | - Arc::new(AlwaysReject), | |
| 396 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 397 | - ); | |
| 398 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 399 | - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 400 | - } | |
| 401 | - | |
| 402 | - #[tokio::test] | |
| 403 | - async fn inbox_returns_400_for_invalid_json() { | |
| 404 | - let router = make_router( | |
| 405 | - Arc::new(AlwaysAccept), | |
| 406 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 407 | - ); | |
| 408 | - let resp = router.oneshot(signed_request("not json")).await.unwrap(); | |
| 409 | - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 410 | - } | |
| 411 | - | |
| 412 | - #[tokio::test] | |
| 413 | - async fn inbox_returns_501_for_unknown_activity_type() { | |
| 414 | - let body = serde_json::json!({ | |
| 415 | - "type": "Like", "actor": "https://a.tld/users/alice" | |
| 416 | - }) | |
| 417 | - .to_string(); | |
| 418 | - let router = make_router( | |
| 419 | - Arc::new(AlwaysAccept), | |
| 420 | - permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 421 | - ); | |
| 422 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 423 | - assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); | |
| 424 | - } | |
| 425 | - | |
| 426 | - #[tokio::test] | |
| 427 | - async fn inbox_smoke_test_with_always_accept_verifier() { | |
| 428 | - use crate::services::fediverse_signal_service::AlwaysAcceptVerifier; | |
| 429 | - let body = serde_json::json!({ | |
| 430 | - "type": "Delete", | |
| 431 | - "actor": "https://a.tld/users/ghost", | |
| 432 | - "object": "https://a.tld/users/ghost" | |
| 433 | - }) | |
| 434 | - .to_string(); | |
| 435 | - let router = make_router(Arc::new(AlwaysAcceptVerifier::new()), db_for_delete_noop()); | |
| 436 | - let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 437 | - assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 438 | - } | |
| 439 | -} |
D
crates/rustwarden-core/src/fediverse/invite_routes.rs
+0
-542
@@ -1,542 +0,0 @@
| 1 | -//! HTTP routes for cross-instance team invitations (RFC 0014). | |
| 2 | -//! | |
| 3 | -//! Six thin handlers around | |
| 4 | -//! [`crate::services::fediverse_invite_service::FediverseInviteService`]. | |
| 5 | -//! | |
| 6 | -//! Inviter-side (`b.tld`) : | |
| 7 | -//! - `POST /api/v1/teams/{team_id}/members/invite-fediverse` | |
| 8 | -//! - `GET /api/v1/teams/{team_id}/invites` | |
| 9 | -//! - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` | |
| 10 | -//! | |
| 11 | -//! IdP-side (`a.tld`) : | |
| 12 | -//! - `GET /api/v1/fediverse/invites?handle=@user@domain` | |
| 13 | -//! - `POST /api/v1/fediverse/invites/{invite_id}/accept` | |
| 14 | -//! - `POST /api/v1/fediverse/invites/{invite_id}/reject` | |
| 15 | -//! | |
| 16 | -//! Gated by `experimental-fediverse`. | |
| 17 | - | |
| 18 | -use std::sync::Arc; | |
| 19 | -use std::time::Duration; | |
| 20 | - | |
| 21 | -use axum::{ | |
| 22 | - Extension, Json, Router, | |
| 23 | - extract::{Path, Query}, | |
| 24 | - http::StatusCode, | |
| 25 | - routing::{get, post}, | |
| 26 | -}; | |
| 27 | -use sea_orm::DatabaseConnection; | |
| 28 | -use serde::{Deserialize, Serialize}; | |
| 29 | -use uuid::Uuid; | |
| 30 | - | |
| 31 | -use super::error::FediverseError; | |
| 32 | -use crate::error::AppError; | |
| 33 | -use crate::services::fediverse_invite_service::FediverseInviteService; | |
| 34 | -use crate::services::team_service::TeamRole; | |
| 35 | - | |
| 36 | -/// Per-router state injected into the invite handlers. | |
| 37 | -#[derive(Clone)] | |
| 38 | -pub struct InviteRoutesState { | |
| 39 | - /// TTL applied to every freshly-issued invite. Defaults to 7 days | |
| 40 | - /// when the consumer does not override (set by | |
| 41 | - /// [`crate::fediverse::routes::router_with_auth`]). | |
| 42 | - pub default_ttl: Duration, | |
| 43 | - /// FK of the user the invite is recorded as having been sent | |
| 44 | - /// **by**. In production the consumer overrides this with the | |
| 45 | - /// authenticated admin's `users.id`; the placeholder makes the | |
| 46 | - /// minimal RFC 0014 slice usable end-to-end without forcing | |
| 47 | - /// every consumer to wire JWT extraction immediately. | |
| 48 | - pub system_actor_user_id: Arc<dyn Fn() -> Uuid + Send + Sync>, | |
| 49 | -} | |
| 50 | - | |
| 51 | -impl std::fmt::Debug for InviteRoutesState { | |
| 52 | - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 53 | - f.debug_struct("InviteRoutesState") | |
| 54 | - .field("default_ttl", &self.default_ttl) | |
| 55 | - .finish_non_exhaustive() | |
| 56 | - } | |
| 57 | -} | |
| 58 | - | |
| 59 | -// ─── Bodies / responses ────────────────────────────────────────────── | |
| 60 | - | |
| 61 | -/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` body. | |
| 62 | -#[derive(Debug, Deserialize)] | |
| 63 | -pub struct InviteBody { | |
| 64 | - /// `@user@domain` to invite. | |
| 65 | - pub handle: String, | |
| 66 | - /// `"member"` or `"admin"`. | |
| 67 | - pub role: String, | |
| 68 | -} | |
| 69 | - | |
| 70 | -/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` 201 response. | |
| 71 | -#[derive(Debug, Serialize)] | |
| 72 | -pub struct InviteResponse { | |
| 73 | - /// Stable invite identifier. | |
| 74 | - pub invite_id: Uuid, | |
| 75 | - /// Canonical AP activity id (used by `b.tld` when sending the | |
| 76 | - /// `Invite{TeamMembership}` activity to the remote inbox). | |
| 77 | - pub activity_id: String, | |
| 78 | - /// RFC3339 expiry timestamp. | |
| 79 | - pub expires_at: String, | |
| 80 | -} | |
| 81 | - | |
| 82 | -/// One row in a `GET /…/invites` listing. | |
| 83 | -#[derive(Debug, Serialize)] | |
| 84 | -pub struct InviteRow { | |
| 85 | - /// Stable invite identifier. | |
| 86 | - pub id: Uuid, | |
| 87 | - /// `@user@domain` invited. | |
| 88 | - pub handle: String, | |
| 89 | - /// `"member"` or `"admin"`. | |
| 90 | - pub role: String, | |
| 91 | - /// Lifecycle state. | |
| 92 | - pub status: String, | |
| 93 | - /// RFC3339 creation timestamp. | |
| 94 | - pub created_at: String, | |
| 95 | - /// RFC3339 expiry timestamp. | |
| 96 | - pub expires_at: String, | |
| 97 | -} | |
| 98 | - | |
| 99 | -/// Query params for the IdP listing endpoint. | |
| 100 | -#[derive(Debug, Deserialize)] | |
| 101 | -pub struct IdpListQuery { | |
| 102 | - /// Handle whose pending invites should be returned. | |
| 103 | - pub handle: String, | |
| 104 | -} | |
| 105 | - | |
| 106 | -// ─── Handlers ──────────────────────────────────────────────────────── | |
| 107 | - | |
| 108 | -async fn invite( | |
| 109 | - Extension(state): Extension<InviteRoutesState>, | |
| 110 | - Extension(db): Extension<DatabaseConnection>, | |
| 111 | - claims: Option<Extension<crate::auth::jwt::Claims>>, | |
| 112 | - Path(team_id): Path<Uuid>, | |
| 113 | - Json(body): Json<InviteBody>, | |
| 114 | -) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> { | |
| 115 | - let role = TeamRole::parse(&body.role) | |
| 116 | - .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?; | |
| 117 | - // Prefer the authenticated admin's id (injected by AuthService for | |
| 118 | - // `/api/v1/teams/*` since iter 4 hygiene). Fall back to the | |
| 119 | - // configured placeholder only when no JWT was present — that path | |
| 120 | - // remains for unit tests with `MockDatabase` and seed-style flows | |
| 121 | - // that don't go through the auth middleware. | |
| 122 | - let inviter = match claims { | |
| 123 | - Some(Extension(c)) => c.sub, | |
| 124 | - None => (state.system_actor_user_id)(), | |
| 125 | - }; | |
| 126 | - let model = FediverseInviteService::invite_remote( | |
| 127 | - &db, | |
| 128 | - team_id, | |
| 129 | - &body.handle, | |
| 130 | - role, | |
| 131 | - inviter, | |
| 132 | - state.default_ttl, | |
| 133 | - ) | |
| 134 | - .await?; | |
| 135 | - Ok(( | |
| 136 | - StatusCode::CREATED, | |
| 137 | - Json(InviteResponse { | |
| 138 | - invite_id: model.id, | |
| 139 | - activity_id: model.activity_id, | |
| 140 | - expires_at: model.expires_at.to_rfc3339(), | |
| 141 | - }), | |
| 142 | - )) | |
| 143 | -} | |
| 144 | - | |
| 145 | -async fn list_for_team( | |
| 146 | - Extension(db): Extension<DatabaseConnection>, | |
| 147 | - Path(team_id): Path<Uuid>, | |
| 148 | -) -> Result<Json<Vec<InviteRow>>, FediverseError> { | |
| 149 | - let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?; | |
| 150 | - Ok(Json(rows.into_iter().map(into_row).collect())) | |
| 151 | -} | |
| 152 | - | |
| 153 | -async fn revoke( | |
| 154 | - Extension(db): Extension<DatabaseConnection>, | |
| 155 | - Path((_team_id, invite_id)): Path<(Uuid, Uuid)>, | |
| 156 | -) -> Result<Json<InviteRow>, FediverseError> { | |
| 157 | - let model = FediverseInviteService::revoke(&db, invite_id).await?; | |
| 158 | - Ok(Json(into_row(model))) | |
| 159 | -} | |
| 160 | - | |
| 161 | -async fn idp_list( | |
| 162 | - Extension(db): Extension<DatabaseConnection>, | |
| 163 | - Query(query): Query<IdpListQuery>, | |
| 164 | -) -> Result<Json<Vec<InviteRow>>, FediverseError> { | |
| 165 | - let rows = | |
| 166 | - FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?; | |
| 167 | - Ok(Json(rows.into_iter().map(into_row).collect())) | |
| 168 | -} | |
| 169 | - | |
| 170 | -#[derive(Debug, Deserialize)] | |
| 171 | -struct AcceptBody { | |
| 172 | - /// FK of the resolved `remote_identities.id` that the IdP user maps | |
| 173 | - /// to. In `debug_local_federation` mode the IdP UI / API picks this | |
| 174 | - /// from the same DB; in production a follow-up RFC will let the | |
| 175 | - /// IdP-side mirror the activity and resolve the row automatically. | |
| 176 | - remote_identity_id: Uuid, | |
| 177 | -} | |
| 178 | - | |
| 179 | -async fn idp_accept( | |
| 180 | - Extension(db): Extension<DatabaseConnection>, | |
| 181 | - Path(invite_id): Path<Uuid>, | |
| 182 | - Json(body): Json<AcceptBody>, | |
| 183 | -) -> Result<StatusCode, FediverseError> { | |
| 184 | - FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?; | |
| 185 | - Ok(StatusCode::OK) | |
| 186 | -} | |
| 187 | - | |
| 188 | -async fn idp_reject( | |
| 189 | - Extension(db): Extension<DatabaseConnection>, | |
| 190 | - Path(invite_id): Path<Uuid>, | |
| 191 | -) -> Result<StatusCode, FediverseError> { | |
| 192 | - FediverseInviteService::reject(&db, invite_id).await?; | |
| 193 | - Ok(StatusCode::OK) | |
| 194 | -} | |
| 195 | - | |
| 196 | -fn into_row(m: crate::models::fediverse_pending_invite::Model) -> InviteRow { | |
| 197 | - InviteRow { | |
| 198 | - id: m.id, | |
| 199 | - handle: m.invited_handle, | |
| 200 | - role: m.role, | |
| 201 | - status: m.status, | |
| 202 | - created_at: m.created_at.to_rfc3339(), | |
| 203 | - expires_at: m.expires_at.to_rfc3339(), | |
| 204 | - } | |
| 205 | -} | |
| 206 | - | |
| 207 | -/// Build the invite-routes router. | |
| 208 | -pub fn router(state: InviteRoutesState) -> Router<DatabaseConnection> { | |
| 209 | - Router::new() | |
| 210 | - .route( | |
| 211 | - "/api/v1/teams/{team_id}/members/invite-fediverse", | |
| 212 | - post(invite), | |
| 213 | - ) | |
| 214 | - .route("/api/v1/teams/{team_id}/invites", get(list_for_team)) | |
| 215 | - .route( | |
| 216 | - "/api/v1/teams/{team_id}/invites/{invite_id}/revoke", | |
| 217 | - post(revoke), | |
| 218 | - ) | |
| 219 | - .route("/api/v1/fediverse/invites", get(idp_list)) | |
| 220 | - .route( | |
| 221 | - "/api/v1/fediverse/invites/{invite_id}/accept", | |
| 222 | - post(idp_accept), | |
| 223 | - ) | |
| 224 | - .route( | |
| 225 | - "/api/v1/fediverse/invites/{invite_id}/reject", | |
| 226 | - post(idp_reject), | |
| 227 | - ) | |
| 228 | - .layer(Extension(state)) | |
| 229 | -} | |
| 230 | - | |
| 231 | -// `From<AppError>` for `FediverseError` is provided via the `App` | |
| 232 | -// variant; nothing extra to wire here. | |
| 233 | -#[allow(dead_code)] | |
| 234 | -fn _ensure_app_error_conversion(err: AppError) -> FediverseError { | |
| 235 | - err.into() | |
| 236 | -} | |
| 237 | - | |
| 238 | -#[cfg(test)] | |
| 239 | -mod tests { | |
| 240 | - use super::*; | |
| 241 | - use crate::models::fediverse_pending_invite; | |
| 242 | - use axum::body::Body; | |
| 243 | - use axum::http::Request; | |
| 244 | - use chrono::{Duration as ChronoDuration, Utc}; | |
| 245 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 246 | - use tower::ServiceExt; | |
| 247 | - | |
| 248 | - fn fresh_invite(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model { | |
| 249 | - let now = Utc::now(); | |
| 250 | - fediverse_pending_invite::Model { | |
| 251 | - id, | |
| 252 | - activity_id: format!("https://b.tld/invites/{id}"), | |
| 253 | - team_id, | |
| 254 | - invited_handle: "alice@a.tld".into(), | |
| 255 | - invited_remote_identity_id: None, | |
| 256 | - role: "member".into(), | |
| 257 | - invited_by_user_id: Uuid::new_v4(), | |
| 258 | - status: "pending".into(), | |
| 259 | - created_at: now, | |
| 260 | - expires_at: now + ChronoDuration::days(7), | |
| 261 | - decided_at: None, | |
| 262 | - } | |
| 263 | - } | |
| 264 | - | |
| 265 | - fn build_router(db: DatabaseConnection) -> Router { | |
| 266 | - let state = InviteRoutesState { | |
| 267 | - default_ttl: Duration::from_secs(7 * 86400), | |
| 268 | - system_actor_user_id: Arc::new(Uuid::new_v4), | |
| 269 | - }; | |
| 270 | - router(state) | |
| 271 | - .layer(Extension(db)) | |
| 272 | - .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) | |
| 273 | - } | |
| 274 | - | |
| 275 | - fn req_post(uri: &str, body: &str) -> Request<Body> { | |
| 276 | - Request::builder() | |
| 277 | - .uri(uri) | |
| 278 | - .method("POST") | |
| 279 | - .header("content-type", "application/json") | |
| 280 | - .body(Body::from(body.to_string())) | |
| 281 | - .unwrap() | |
| 282 | - } | |
| 283 | - | |
| 284 | - fn req_get(uri: &str) -> Request<Body> { | |
| 285 | - Request::builder() | |
| 286 | - .uri(uri) | |
| 287 | - .method("GET") | |
| 288 | - .body(Body::empty()) | |
| 289 | - .unwrap() | |
| 290 | - } | |
| 291 | - | |
| 292 | - // ─── Inviter side ─────────────────────────────────────────────── | |
| 293 | - | |
| 294 | - #[tokio::test] | |
| 295 | - async fn invite_returns_201_with_invite_id() { | |
| 296 | - let team_id = Uuid::new_v4(); | |
| 297 | - let row = fresh_invite(Uuid::new_v4(), team_id); | |
| 298 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 299 | - .append_query_results([vec![row]]) | |
| 300 | - .into_connection(); | |
| 301 | - let body = serde_json::json!({"handle":"alice@a.tld","role":"member"}).to_string(); | |
| 302 | - let resp = build_router(db) | |
| 303 | - .oneshot(req_post( | |
| 304 | - &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 305 | - &body, | |
| 306 | - )) | |
| 307 | - .await | |
| 308 | - .unwrap(); | |
| 309 | - assert_eq!(resp.status(), StatusCode::CREATED); | |
| 310 | - } | |
| 311 | - | |
| 312 | - #[tokio::test] | |
| 313 | - async fn invite_returns_400_for_invalid_role() { | |
| 314 | - let team_id = Uuid::new_v4(); | |
| 315 | - let db: DatabaseConnection = | |
| 316 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 317 | - let body = serde_json::json!({"handle":"alice@a.tld","role":"superuser"}).to_string(); | |
| 318 | - let resp = build_router(db) | |
| 319 | - .oneshot(req_post( | |
| 320 | - &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 321 | - &body, | |
| 322 | - )) | |
| 323 | - .await | |
| 324 | - .unwrap(); | |
| 325 | - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 326 | - } | |
| 327 | - | |
| 328 | - #[tokio::test] | |
| 329 | - async fn list_pending_returns_200_with_array() { | |
| 330 | - let team_id = Uuid::new_v4(); | |
| 331 | - let r1 = fresh_invite(Uuid::new_v4(), team_id); | |
| 332 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 333 | - .append_query_results([vec![r1]]) | |
| 334 | - .into_connection(); | |
| 335 | - let resp = build_router(db) | |
| 336 | - .oneshot(req_get(&format!("/api/v1/teams/{team_id}/invites"))) | |
| 337 | - .await | |
| 338 | - .unwrap(); | |
| 339 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 340 | - } | |
| 341 | - | |
| 342 | - #[tokio::test] | |
| 343 | - async fn revoke_returns_200_for_pending_invite() { | |
| 344 | - let team_id = Uuid::new_v4(); | |
| 345 | - let invite_id = Uuid::new_v4(); | |
| 346 | - let row = fresh_invite(invite_id, team_id); | |
| 347 | - let mut after = row.clone(); | |
| 348 | - after.status = "revoked".into(); | |
| 349 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 350 | - .append_query_results([vec![row]]) | |
| 351 | - .append_query_results([vec![after]]) | |
| 352 | - .append_exec_results([MockExecResult { | |
| 353 | - last_insert_id: 0, | |
| 354 | - rows_affected: 1, | |
| 355 | - }]) | |
| 356 | - .into_connection(); | |
| 357 | - let resp = build_router(db) | |
| 358 | - .oneshot(req_post( | |
| 359 | - &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), | |
| 360 | - "", | |
| 361 | - )) | |
| 362 | - .await | |
| 363 | - .unwrap(); | |
| 364 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 365 | - } | |
| 366 | - | |
| 367 | - #[tokio::test] | |
| 368 | - async fn revoke_returns_404_for_unknown_invite() { | |
| 369 | - let team_id = Uuid::new_v4(); | |
| 370 | - let invite_id = Uuid::new_v4(); | |
| 371 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 372 | - .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) | |
| 373 | - .into_connection(); | |
| 374 | - let resp = build_router(db) | |
| 375 | - .oneshot(req_post( | |
| 376 | - &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), | |
| 377 | - "", | |
| 378 | - )) | |
| 379 | - .await | |
| 380 | - .unwrap(); | |
| 381 | - assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 382 | - } | |
| 383 | - | |
| 384 | - // ─── IdP side ────────────────────────────────────────────────── | |
| 385 | - | |
| 386 | - #[tokio::test] | |
| 387 | - async fn idp_list_returns_200_with_invites_for_handle() { | |
| 388 | - let team_id = Uuid::new_v4(); | |
| 389 | - let r1 = fresh_invite(Uuid::new_v4(), team_id); | |
| 390 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 391 | - .append_query_results([vec![r1]]) | |
| 392 | - .into_connection(); | |
| 393 | - let resp = build_router(db) | |
| 394 | - .oneshot(req_get("/api/v1/fediverse/invites?handle=alice@a.tld")) | |
| 395 | - .await | |
| 396 | - .unwrap(); | |
| 397 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 398 | - } | |
| 399 | - | |
| 400 | - #[tokio::test] | |
| 401 | - async fn idp_list_returns_empty_array_when_no_invites() { | |
| 402 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 403 | - .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) | |
| 404 | - .into_connection(); | |
| 405 | - let resp = build_router(db) | |
| 406 | - .oneshot(req_get("/api/v1/fediverse/invites?handle=ghost@nowhere.tld")) | |
| 407 | - .await | |
| 408 | - .unwrap(); | |
| 409 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 410 | - } | |
| 411 | - | |
| 412 | - #[tokio::test] | |
| 413 | - async fn idp_accept_returns_200_for_pending() { | |
| 414 | - let team_id = Uuid::new_v4(); | |
| 415 | - let invite_id = Uuid::new_v4(); | |
| 416 | - let remote_id = Uuid::new_v4(); | |
| 417 | - let row = fresh_invite(invite_id, team_id); | |
| 418 | - let mut after_invite = row.clone(); | |
| 419 | - after_invite.status = "accepted".into(); | |
| 420 | - after_invite.invited_remote_identity_id = Some(remote_id); | |
| 421 | - let now = Utc::now(); | |
| 422 | - let new_member = crate::models::team_member::Model { | |
| 423 | - id: Uuid::new_v4(), | |
| 424 | - team_id, | |
| 425 | - user_id: None, | |
| 426 | - role: "member".into(), | |
| 427 | - joined_at: now, | |
| 428 | - remote_identity_id: Some(remote_id), | |
| 429 | - }; | |
| 430 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 431 | - .append_query_results([vec![row]]) | |
| 432 | - .append_query_results([vec![after_invite]]) | |
| 433 | - .append_query_results([vec![new_member]]) | |
| 434 | - .append_exec_results([ | |
| 435 | - MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 436 | - MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 437 | - ]) | |
| 438 | - .into_connection(); | |
| 439 | - let body = serde_json::json!({"remote_identity_id":remote_id}).to_string(); | |
| 440 | - let resp = build_router(db) | |
| 441 | - .oneshot(req_post( | |
| 442 | - &format!("/api/v1/fediverse/invites/{invite_id}/accept"), | |
| 443 | - &body, | |
| 444 | - )) | |
| 445 | - .await | |
| 446 | - .unwrap(); | |
| 447 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 448 | - } | |
| 449 | - | |
| 450 | - #[tokio::test] | |
| 451 | - async fn idp_accept_returns_400_for_already_accepted() { | |
| 452 | - let team_id = Uuid::new_v4(); | |
| 453 | - let invite_id = Uuid::new_v4(); | |
| 454 | - let mut row = fresh_invite(invite_id, team_id); | |
| 455 | - row.status = "accepted".into(); | |
| 456 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 457 | - .append_query_results([vec![row]]) | |
| 458 | - .into_connection(); | |
| 459 | - let body = serde_json::json!({"remote_identity_id":Uuid::new_v4()}).to_string(); | |
| 460 | - let resp = build_router(db) | |
| 461 | - .oneshot(req_post( | |
| 462 | - &format!("/api/v1/fediverse/invites/{invite_id}/accept"), | |
| 463 | - &body, | |
| 464 | - )) | |
| 465 | - .await | |
| 466 | - .unwrap(); | |
| 467 | - assert!( | |
| 468 | - resp.status() == StatusCode::BAD_REQUEST | |
| 469 | - || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 470 | - "expected 400/422, got {}", | |
| 471 | - resp.status() | |
| 472 | - ); | |
| 473 | - } | |
| 474 | - | |
| 475 | - #[tokio::test] | |
| 476 | - async fn idp_reject_returns_200_for_pending() { | |
| 477 | - let team_id = Uuid::new_v4(); | |
| 478 | - let invite_id = Uuid::new_v4(); | |
| 479 | - let row = fresh_invite(invite_id, team_id); | |
| 480 | - let mut after = row.clone(); | |
| 481 | - after.status = "rejected".into(); | |
| 482 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 483 | - .append_query_results([vec![row]]) | |
| 484 | - .append_query_results([vec![after]]) | |
| 485 | - .append_exec_results([MockExecResult { | |
| 486 | - last_insert_id: 0, | |
| 487 | - rows_affected: 1, | |
| 488 | - }]) | |
| 489 | - .into_connection(); | |
| 490 | - let resp = build_router(db) | |
| 491 | - .oneshot(req_post( | |
| 492 | - &format!("/api/v1/fediverse/invites/{invite_id}/reject"), | |
| 493 | - "", | |
| 494 | - )) | |
| 495 | - .await | |
| 496 | - .unwrap(); | |
| 497 | - assert_eq!(resp.status(), StatusCode::OK); | |
| 498 | - } | |
| 499 | - | |
| 500 | - #[tokio::test] | |
| 501 | - async fn idp_reject_returns_400_for_already_decided() { | |
| 502 | - let team_id = Uuid::new_v4(); | |
| 503 | - let invite_id = Uuid::new_v4(); | |
| 504 | - let mut row = fresh_invite(invite_id, team_id); | |
| 505 | - row.status = "rejected".into(); | |
| 506 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 507 | - .append_query_results([vec![row]]) | |
| 508 | - .into_connection(); | |
| 509 | - let resp = build_router(db) | |
| 510 | - .oneshot(req_post( | |
| 511 | - &format!("/api/v1/fediverse/invites/{invite_id}/reject"), | |
| 512 | - "", | |
| 513 | - )) | |
| 514 | - .await | |
| 515 | - .unwrap(); | |
| 516 | - assert!( | |
| 517 | - resp.status() == StatusCode::BAD_REQUEST | |
| 518 | - || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 519 | - ); | |
| 520 | - } | |
| 521 | - | |
| 522 | - #[tokio::test] | |
| 523 | - async fn invite_uses_default_ttl_from_state() { | |
| 524 | - // The `default_ttl` flows through the service; the mock | |
| 525 | - // doesn't reflect it back to the response, so we only | |
| 526 | - // assert status 201 and that the row was inserted. | |
| 527 | - let team_id = Uuid::new_v4(); | |
| 528 | - let row = fresh_invite(Uuid::new_v4(), team_id); | |
| 529 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 530 | - .append_query_results([vec![row]]) | |
| 531 | - .into_connection(); | |
| 532 | - let body = serde_json::json!({"handle":"alice@a.tld","role":"admin"}).to_string(); | |
| 533 | - let resp = build_router(db) | |
| 534 | - .oneshot(req_post( | |
| 535 | - &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 536 | - &body, | |
| 537 | - )) | |
| 538 | - .await | |
| 539 | - .unwrap(); | |
| 540 | - assert_eq!(resp.status(), StatusCode::CREATED); | |
| 541 | - } | |
| 542 | -} |
D
crates/rustwarden-core/src/fediverse/mod.rs
+0
-38
@@ -1,38 +0,0 @@
| 1 | -//! ActivityPub identity federation primitives (RFC 0001+). | |
| 2 | -//! | |
| 3 | -//! This module is gated behind the `experimental-fediverse` Cargo feature. | |
| 4 | -//! It implements the discovery layer (RFC 0002): WebFinger, Person actor, | |
| 5 | -//! and NodeInfo. Subsequent RFCs add authentication (0006), invitations | |
| 6 | -//! (0009), lifecycle signals (0010), and the outbound delivery worker (0011). | |
| 7 | - | |
| 8 | -/// Person actor handler with `Accept` content negotiation. | |
| 9 | -pub mod actor; | |
| 10 | -/// Federation-specific error type, convertible to [`AppError`](crate::error::AppError). | |
| 11 | -pub mod error; | |
| 12 | -/// HTTP client abstraction used by `RemoteIdentityService` (RFC 0003). | |
| 13 | -pub mod fetcher; | |
| 14 | -/// `POST /inbox` handler dispatching AP lifecycle activities (RFC 0012). | |
| 15 | -pub mod inbox; | |
| 16 | -/// HTTP routes for the AP-native authentication flow (RFC 0013). | |
| 17 | -pub mod auth_routes; | |
| 18 | -/// HTTP routes for cross-instance team invitations (RFC 0014). | |
| 19 | -pub mod invite_routes; | |
| 20 | -/// Mastodon-compatible HTTP signatures (draft-cavage RSA-SHA256, RFC 0015). | |
| 21 | -pub mod http_signatures; | |
| 22 | -/// NodeInfo discovery + 2.1 handlers. | |
| 23 | -pub mod nodeinfo; | |
| 24 | -/// `LocalUserResolver` trait + SeaORM-backed default implementation. | |
| 25 | -pub mod resolver; | |
| 26 | -/// Router builder: assembles the federation Axum router. | |
| 27 | -pub mod routes; | |
| 28 | -/// Shared state injected into federation handlers. | |
| 29 | -pub mod state; | |
| 30 | -/// JSON types serialised on federation endpoints. | |
| 31 | -pub mod types; | |
| 32 | -/// WebFinger handler (RFC 7033). | |
| 33 | -pub mod webfinger; | |
| 34 | - | |
| 35 | -pub use error::FediverseError; | |
| 36 | -pub use fetcher::{RemoteFetcher, UnconfiguredFetcher}; | |
| 37 | -pub use resolver::{LocalUserResolver, UserSummary}; | |
| 38 | -pub use state::FediverseState; |
D
crates/rustwarden-core/src/fediverse/nodeinfo.rs
+0
-194
@@ -1,194 +0,0 @@
| 1 | -//! NodeInfo discovery + 2.1 detail handlers. | |
| 2 | -//! | |
| 3 | -//! `GET /.well-known/nodeinfo` returns a discovery document linking to the | |
| 4 | -//! concrete schema versions we expose. `GET /nodeinfo/2.1` returns the | |
| 5 | -//! full metadata document used by Fediverse directories. | |
| 6 | - | |
| 7 | -use axum::{Extension, Json}; | |
| 8 | - | |
| 9 | -use super::error::FediverseError; | |
| 10 | -use super::state::FediverseState; | |
| 11 | -use super::types::{ | |
| 12 | - NodeInfo, NodeInfoDiscovery, NodeInfoLink, NodeInfoServices, NodeInfoSoftware, NodeInfoUsage, | |
| 13 | - NodeInfoUsageUsers, | |
| 14 | -}; | |
| 15 | - | |
| 16 | -const NODEINFO_2_1_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.1"; | |
| 17 | - | |
| 18 | -/// `GET /.well-known/nodeinfo`. | |
| 19 | -pub async fn discovery( | |
| 20 | - Extension(state): Extension<FediverseState>, | |
| 21 | -) -> Result<Json<NodeInfoDiscovery>, FediverseError> { | |
| 22 | - let href = format!("{}nodeinfo/2.1", state.config.base_url()); | |
| 23 | - Ok(Json(NodeInfoDiscovery { | |
| 24 | - links: vec![NodeInfoLink { | |
| 25 | - rel: NODEINFO_2_1_REL.to_string(), | |
| 26 | - href, | |
| 27 | - }], | |
| 28 | - })) | |
| 29 | -} | |
| 30 | - | |
| 31 | -/// `GET /nodeinfo/2.1`. | |
| 32 | -pub async fn detail( | |
| 33 | - Extension(state): Extension<FediverseState>, | |
| 34 | -) -> Result<Json<NodeInfo>, FediverseError> { | |
| 35 | - let total = state.resolver.count_users_total().await?; | |
| 36 | - let active_month = state.resolver.count_users_active_since(30).await?; | |
| 37 | - let active_halfyear = state.resolver.count_users_active_since(180).await?; | |
| 38 | - let local_posts = state.resolver.count_local_posts().await?; | |
| 39 | - | |
| 40 | - Ok(Json(NodeInfo { | |
| 41 | - version: "2.1".to_string(), | |
| 42 | - software: NodeInfoSoftware { | |
| 43 | - name: "rustwarden".to_string(), | |
| 44 | - version: env!("CARGO_PKG_VERSION").to_string(), | |
| 45 | - repository: Some("https://github.com/EBO/rustwarden".to_string()), | |
| 46 | - homepage: None, | |
| 47 | - }, | |
| 48 | - protocols: vec!["activitypub".to_string()], | |
| 49 | - services: NodeInfoServices::default(), | |
| 50 | - usage: NodeInfoUsage { | |
| 51 | - users: NodeInfoUsageUsers { | |
| 52 | - total, | |
| 53 | - active_month, | |
| 54 | - active_halfyear, | |
| 55 | - }, | |
| 56 | - local_posts, | |
| 57 | - }, | |
| 58 | - // RFC 0004 will source this from `app_settings.allow_signup`. For now | |
| 59 | - // a conservative default: closed registration. | |
| 60 | - open_registrations: false, | |
| 61 | - metadata: serde_json::json!({}), | |
| 62 | - })) | |
| 63 | -} | |
| 64 | - | |
| 65 | -#[cfg(test)] | |
| 66 | -mod tests { | |
| 67 | - use super::*; | |
| 68 | - use crate::config::FediverseConfig; | |
| 69 | - use crate::fediverse::resolver::{LocalUserResolver, UserSummary}; | |
| 70 | - use async_trait::async_trait; | |
| 71 | - use axum::Router; | |
| 72 | - use axum::body::Body; | |
| 73 | - use axum::http::{Request, StatusCode}; | |
| 74 | - use axum::routing::get; | |
| 75 | - use std::sync::Arc; | |
| 76 | - use tower::ServiceExt; | |
| 77 | - | |
| 78 | - struct StubResolver { | |
| 79 | - total: u64, | |
| 80 | - active_month: u64, | |
| 81 | - active_halfyear: u64, | |
| 82 | - } | |
| 83 | - | |
| 84 | - #[async_trait] | |
| 85 | - impl LocalUserResolver for StubResolver { | |
| 86 | - async fn find_local_user( | |
| 87 | - &self, | |
| 88 | - _username: &str, | |
| 89 | - ) -> Result<Option<UserSummary>, crate::error::AppError> { | |
| 90 | - Ok(None) | |
| 91 | - } | |
| 92 | - async fn count_users_total(&self) -> Result<u64, crate::error::AppError> { | |
| 93 | - Ok(self.total) | |
| 94 | - } | |
| 95 | - async fn count_users_active_since(&self, days: u32) -> Result<u64, crate::error::AppError> { | |
| 96 | - Ok(if days <= 30 { | |
| 97 | - self.active_month | |
| 98 | - } else { | |
| 99 | - self.active_halfyear | |
| 100 | - }) | |
| 101 | - } | |
| 102 | - async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> { | |
| 103 | - Ok(0) | |
| 104 | - } | |
| 105 | - } | |
| 106 | - | |
| 107 | - fn router(domain: &str, total: u64, active_month: u64, active_halfyear: u64) -> Router { | |
| 108 | - let state = FediverseState { | |
| 109 | - config: FediverseConfig::for_tests(domain), | |
| 110 | - resolver: Arc::new(StubResolver { | |
| 111 | - total, | |
| 112 | - active_month, | |
| 113 | - active_halfyear, | |
| 114 | - }), | |
| 115 | - }; | |
| 116 | - Router::new() | |
| 117 | - .route("/.well-known/nodeinfo", get(discovery)) | |
| 118 | - .route("/nodeinfo/2.1", get(detail)) | |
| 119 | - .layer(Extension(state)) | |
| 120 | - } | |
| 121 | - | |
| 122 | - async fn body_json(response: axum::response::Response) -> serde_json::Value { | |
| 123 | - let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 124 | - .await | |
| 125 | - .expect("body bytes"); | |
| 126 | - serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 127 | - } | |
| 128 | - | |
| 129 | - #[tokio::test] | |
| 130 | - async fn nodeinfo_discovery_links_to_2_1() { | |
| 131 | - let app = router("example.org", 1, 0, 0); | |
| 132 | - let response = app | |
| 133 | - .oneshot( | |
| 134 | - Request::builder() | |
| 135 | - .uri("/.well-known/nodeinfo") | |
| 136 | - .body(Body::empty()) | |
| 137 | - .unwrap(), | |
| 138 | - ) | |
| 139 | - .await | |
| 140 | - .unwrap(); | |
| 141 | - assert_eq!(response.status(), StatusCode::OK); | |
| 142 | - let body = body_json(response).await; | |
| 143 | - let link = body["links"] | |
| 144 | - .as_array() | |
| 145 | - .expect("links") | |
| 146 | - .iter() | |
| 147 | - .find(|l| l["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1") | |
| 148 | - .expect("2.1 schema link"); | |
| 149 | - assert_eq!(link["href"], "https://example.org/nodeinfo/2.1"); | |
| 150 | - } | |
| 151 | - | |
| 152 | - #[tokio::test] | |
| 153 | - async fn nodeinfo_detail_returns_software_metadata() { | |
| 154 | - let app = router("example.org", 0, 0, 0); | |
| 155 | - let response = app | |
| 156 | - .oneshot( | |
| 157 | - Request::builder() | |
| 158 | - .uri("/nodeinfo/2.1") | |
| 159 | - .body(Body::empty()) | |
| 160 | - .unwrap(), | |
| 161 | - ) | |
| 162 | - .await | |
| 163 | - .unwrap(); | |
| 164 | - assert_eq!(response.status(), StatusCode::OK); | |
| 165 | - let body = body_json(response).await; | |
| 166 | - assert_eq!(body["version"], "2.1"); | |
| 167 | - assert_eq!(body["software"]["name"], "rustwarden"); | |
| 168 | - assert_eq!( | |
| 169 | - body["software"]["version"], | |
| 170 | - env!("CARGO_PKG_VERSION"), | |
| 171 | - "software.version must mirror CARGO_PKG_VERSION" | |
| 172 | - ); | |
| 173 | - assert_eq!(body["protocols"][0], "activitypub"); | |
| 174 | - } | |
| 175 | - | |
| 176 | - #[tokio::test] | |
| 177 | - async fn nodeinfo_detail_counts_users_via_resolver() { | |
| 178 | - let app = router("example.org", 42, 7, 23); | |
| 179 | - let response = app | |
| 180 | - .oneshot( | |
| 181 | - Request::builder() | |
| 182 | - .uri("/nodeinfo/2.1") | |
| 183 | - .body(Body::empty()) | |
| 184 | - .unwrap(), | |
| 185 | - ) | |
| 186 | - .await | |
| 187 | - .unwrap(); | |
| 188 | - let body = body_json(response).await; | |
| 189 | - assert_eq!(body["usage"]["users"]["total"], 42); | |
| 190 | - assert_eq!(body["usage"]["users"]["activeMonth"], 7); | |
| 191 | - assert_eq!(body["usage"]["users"]["activeHalfyear"], 23); | |
| 192 | - assert_eq!(body["usage"]["localPosts"], 0); | |
| 193 | - } | |
| 194 | -} |
D
crates/rustwarden-core/src/fediverse/resolver.rs
+0
-147
@@ -1,147 +0,0 @@
| 1 | -//! `LocalUserResolver` trait + SeaORM-backed default implementation. | |
| 2 | -//! | |
| 3 | -//! Federation handlers depend on this trait rather than directly on SeaORM, | |
| 4 | -//! which keeps unit tests simple (we inject a stub resolver) and lets future | |
| 5 | -//! deployments swap the backing store without touching the handler code. | |
| 6 | - | |
| 7 | -use async_trait::async_trait; | |
| 8 | -use sea_orm::DatabaseConnection; | |
| 9 | -use uuid::Uuid; | |
| 10 | - | |
| 11 | -use crate::error::AppError; | |
| 12 | - | |
| 13 | -/// Minimal projection of a local user usable to render WebFinger and | |
| 14 | -/// Person JSON. Extended by [RFC 0005](../../rfcs/0005-actor-key-storage.md) | |
| 15 | -/// (`actor-key-storage`) with the `ap_public_key_pem` field populated. | |
| 16 | -#[derive(Debug, Clone)] | |
| 17 | -pub struct UserSummary { | |
| 18 | - /// Stable internal user identifier. | |
| 19 | - pub id: Uuid, | |
| 20 | - /// Local username (matches the `alice` part of `@alice@example.org`). | |
| 21 | - pub username: String, | |
| 22 | - /// Optional display name (Mastodon `name` field). | |
| 23 | - pub display_name: Option<String>, | |
| 24 | - /// Optional bio in HTML (Mastodon `summary` field). | |
| 25 | - pub bio: Option<String>, | |
| 26 | - /// PEM-encoded RSA public key. `None` until RFC 0005 lands. | |
| 27 | - pub ap_public_key_pem: Option<String>, | |
| 28 | -} | |
| 29 | - | |
| 30 | -/// Read-only projection of users used by federation handlers. | |
| 31 | -/// | |
| 32 | -/// Concrete implementations provide a thin facade over the underlying user | |
| 33 | -/// store. The default is [`SeaOrmLocalUserResolver`]; tests can inject their | |
| 34 | -/// own implementation. | |
| 35 | -#[async_trait] | |
| 36 | -pub trait LocalUserResolver: Send + Sync { | |
| 37 | - /// Look up a local user by username. | |
| 38 | - /// | |
| 39 | - /// Returns `Ok(None)` when no such user exists or the user is | |
| 40 | - /// soft-deleted. Returns `Err(AppError::Database(_))` on transport errors. | |
| 41 | - async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError>; | |
| 42 | - | |
| 43 | - /// Total number of registered users (NodeInfo `usage.users.total`). | |
| 44 | - async fn count_users_total(&self) -> Result<u64, AppError>; | |
| 45 | - | |
| 46 | - /// Number of users active within the given window. | |
| 47 | - /// | |
| 48 | - /// "Active" is defined as having `last_login_at` within the past | |
| 49 | - /// `days` days. Used to populate `usage.users.active_month` (`days = 30`) | |
| 50 | - /// and `usage.users.active_halfyear` (`days = 180`). | |
| 51 | - async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>; | |
| 52 | - | |
| 53 | - /// Total number of locally-authored posts (NodeInfo `usage.localPosts`). | |
| 54 | - /// Returns `0` until later RFCs introduce post entities. | |
| 55 | - async fn count_local_posts(&self) -> Result<u64, AppError>; | |
| 56 | -} | |
| 57 | - | |
| 58 | -/// Default [`LocalUserResolver`] implementation backed by a SeaORM | |
| 59 | -/// [`DatabaseConnection`]. Cheap to clone (the connection is `Arc`-backed). | |
| 60 | -#[derive(Debug, Clone)] | |
| 61 | -pub struct SeaOrmLocalUserResolver { | |
| 62 | - db: DatabaseConnection, | |
| 63 | -} | |
| 64 | - | |
| 65 | -impl SeaOrmLocalUserResolver { | |
| 66 | - /// Build a resolver around an existing SeaORM connection. | |
| 67 | - pub fn new(db: DatabaseConnection) -> Self { | |
| 68 | - Self { db } | |
| 69 | - } | |
| 70 | -} | |
| 71 | - | |
| 72 | -#[async_trait] | |
| 73 | -impl LocalUserResolver for SeaOrmLocalUserResolver { | |
| 74 | - async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError> { | |
| 75 | - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 76 | - | |
| 77 | - let row = crate::models::user::Entity::find() | |
| 78 | - .filter(crate::models::user::Column::Username.eq(username)) | |
| 79 | - .one(&self.db) | |
| 80 | - .await?; | |
| 81 | - | |
| 82 | - let Some(r) = row else { return Ok(None) }; | |
| 83 | - | |
| 84 | - // RFC 0005: lazy-generate the actor key the first time someone | |
| 85 | - // resolves the user, but only if encryption is configured. We | |
| 86 | - // never block the resolution on a generation failure: better to | |
| 87 | - // serve a key-less actor than to 500 the WebFinger flow. | |
| 88 | - let public_pem = match &r.ap_public_key_pem { | |
| 89 | - Some(pem) => Some(pem.clone()), | |
| 90 | - None => { | |
| 91 | - if crate::services::crypto_service::CryptoService::is_available() { | |
| 92 | - match crate::services::actor_key_service::ActorKeyService::ensure_keys( | |
| 93 | - &self.db, r.id, | |
| 94 | - ) | |
| 95 | - .await | |
| 96 | - { | |
| 97 | - Ok(material) => Some(material.public_pem), | |
| 98 | - Err(err) => { | |
| 99 | - tracing::warn!( | |
| 100 | - user_id = %r.id, | |
| 101 | - error = %err, | |
| 102 | - "actor key generation failed, serving key-less actor", | |
| 103 | - ); | |
| 104 | - None | |
| 105 | - } | |
| 106 | - } | |
| 107 | - } else { | |
| 108 | - None | |
| 109 | - } | |
| 110 | - } | |
| 111 | - }; | |
| 112 | - | |
| 113 | - Ok(Some(UserSummary { | |
| 114 | - id: r.id, | |
| 115 | - username: r.username, | |
| 116 | - // `display_name` / `bio` columns are not in the schema yet — a | |
| 117 | - // future RFC will add them. | |
| 118 | - display_name: None, | |
| 119 | - bio: None, | |
| 120 | - ap_public_key_pem: public_pem, | |
| 121 | - })) | |
| 122 | - } | |
| 123 | - | |
| 124 | - async fn count_users_total(&self) -> Result<u64, AppError> { | |
| 125 | - use sea_orm::{EntityTrait, PaginatorTrait}; | |
| 126 | - Ok(crate::models::user::Entity::find() | |
| 127 | - .count(&self.db) | |
| 128 | - .await?) | |
| 129 | - } | |
| 130 | - | |
| 131 | - async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError> { | |
| 132 | - use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; | |
| 133 | - | |
| 134 | - let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64); | |
| 135 | - Ok(crate::models::user::Entity::find() | |
| 136 | - .filter(crate::models::user::Column::LastLoginAt.gte(cutoff)) | |
| 137 | - .count(&self.db) | |
| 138 | - .await?) | |
| 139 | - } | |
| 140 | - | |
| 141 | - async fn count_local_posts(&self) -> Result<u64, AppError> { | |
| 142 | - // RFC 0002 — no post entity yet, always zero. Future RFCs that | |
| 143 | - // introduce locally-authored posts will override this method or | |
| 144 | - // ship a dedicated resolver. | |
| 145 | - Ok(0) | |
| 146 | - } | |
| 147 | -} |
D
crates/rustwarden-core/src/fediverse/routes.rs
+0
-137
@@ -1,137 +0,0 @@
| 1 | -//! Builder for the federation Axum router. | |
| 2 | -//! | |
| 3 | -//! Assembles the four discovery routes (`webfinger`, `nodeinfo` discovery, | |
| 4 | -//! `nodeinfo` detail, actor) into a single `Router` ready to be merged into | |
| 5 | -//! the application by [`crate::app::RustwardenBuilder::build`]. | |
| 6 | - | |
| 7 | -use std::sync::Arc; | |
| 8 | - | |
| 9 | -use axum::{ | |
| 10 | - Extension, Router, | |
| 11 | - routing::{get, post}, | |
| 12 | -}; | |
| 13 | -use sea_orm::DatabaseConnection; | |
| 14 | - | |
| 15 | -use crate::config::FediverseConfig; | |
| 16 | -use crate::config::auth::AuthConfig; | |
| 17 | -use crate::services::fediverse_signal_service::{AlwaysAcceptVerifier, SignatureVerifier}; | |
| 18 | - | |
| 19 | -use super::actor; | |
| 20 | -use super::auth_routes::{self, AuthFlowState}; | |
| 21 | -use super::inbox::{self, InboxState}; | |
| 22 | -use super::invite_routes::{self, InviteRoutesState}; | |
| 23 | -use super::nodeinfo; | |
| 24 | -use super::resolver::{LocalUserResolver, SeaOrmLocalUserResolver}; | |
| 25 | -use super::state::FediverseState; | |
| 26 | -use super::webfinger; | |
| 27 | - | |
| 28 | -/// Build the federation router with the default SeaORM-backed resolver | |
| 29 | -/// and the test-only [`AlwaysAcceptVerifier`]. | |
| 30 | -pub fn router(config: FediverseConfig, db: DatabaseConnection) -> Router<DatabaseConnection> { | |
| 31 | - let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone())); | |
| 32 | - // Debug/dev verifier with cache-population side-effect so the | |
| 33 | - // testbed (FEDIVERSE_DEBUG_LOCAL_FEDERATION=true) doesn't need to | |
| 34 | - // manually pre-seed `remote_identities`. | |
| 35 | - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::with_resolver(db)); | |
| 36 | - router_with(config, resolver, verifier) | |
| 37 | -} | |
| 38 | - | |
| 39 | -/// Build the federation router with custom resolver and signature | |
| 40 | -/// verifier implementations. Used by integration tests, consumers that | |
| 41 | -/// already maintain their own user store, or apps wanting to plug a | |
| 42 | -/// production HTTP-Sig verifier (RFC 0012). | |
| 43 | -pub fn router_with( | |
| 44 | - config: FediverseConfig, | |
| 45 | - resolver: Arc<dyn LocalUserResolver>, | |
| 46 | - verifier: Arc<dyn SignatureVerifier>, | |
| 47 | -) -> Router<DatabaseConnection> { | |
| 48 | - let challenge_ttl = config.auth_challenge_ttl(); | |
| 49 | - let state = FediverseState { config, resolver }; | |
| 50 | - let inbox_state = InboxState { | |
| 51 | - verifier: verifier.clone(), | |
| 52 | - }; | |
| 53 | - let auth_flow_state = AuthFlowState { | |
| 54 | - auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())), | |
| 55 | - verifier, | |
| 56 | - challenge_ttl, | |
| 57 | - }; | |
| 58 | - Router::new() | |
| 59 | - .route("/.well-known/webfinger", get(webfinger::handle)) | |
| 60 | - .route("/.well-known/nodeinfo", get(nodeinfo::discovery)) | |
| 61 | - .route("/nodeinfo/2.1", get(nodeinfo::detail)) | |
| 62 | - .route("/users/{username}", get(actor::handle)) | |
| 63 | - .route("/inbox", post(inbox::handle)) | |
| 64 | - .merge(auth_routes::router(auth_flow_state)) | |
| 65 | - .merge(invite_routes::router(InviteRoutesState { | |
| 66 | - default_ttl: std::time::Duration::from_secs(7 * 86400), | |
| 67 | - system_actor_user_id: Arc::new(uuid::Uuid::new_v4), | |
| 68 | - })) | |
| 69 | - .layer(Extension(state)) | |
| 70 | - .layer(Extension(inbox_state)) | |
| 71 | -} | |
| 72 | - | |
| 73 | -/// Build the federation router with explicit `AuthConfig` injection. | |
| 74 | -/// Use this entry point when the consumer cannot rely on env vars | |
| 75 | -/// being set at router-build time. | |
| 76 | -pub fn router_with_auth( | |
| 77 | - config: FediverseConfig, | |
| 78 | - resolver: Arc<dyn LocalUserResolver>, | |
| 79 | - verifier: Arc<dyn SignatureVerifier>, | |
| 80 | - auth_config: Arc<AuthConfig>, | |
| 81 | -) -> Router<DatabaseConnection> { | |
| 82 | - let challenge_ttl = config.auth_challenge_ttl(); | |
| 83 | - let state = FediverseState { config, resolver }; | |
| 84 | - let inbox_state = InboxState { | |
| 85 | - verifier: verifier.clone(), | |
| 86 | - }; | |
| 87 | - let auth_flow_state = AuthFlowState { | |
| 88 | - auth_config, | |
| 89 | - verifier, | |
| 90 | - challenge_ttl, | |
| 91 | - }; | |
| 92 | - Router::new() | |
| 93 | - .route("/.well-known/webfinger", get(webfinger::handle)) | |
| 94 | - .route("/.well-known/nodeinfo", get(nodeinfo::discovery)) | |
| 95 | - .route("/nodeinfo/2.1", get(nodeinfo::detail)) | |
| 96 | - .route("/users/{username}", get(actor::handle)) | |
| 97 | - .route("/inbox", post(inbox::handle)) | |
| 98 | - .merge(auth_routes::router(auth_flow_state)) | |
| 99 | - .merge(invite_routes::router(InviteRoutesState { | |
| 100 | - default_ttl: std::time::Duration::from_secs(7 * 86400), | |
| 101 | - system_actor_user_id: Arc::new(uuid::Uuid::new_v4), | |
| 102 | - })) | |
| 103 | - .layer(Extension(state)) | |
| 104 | - .layer(Extension(inbox_state)) | |
| 105 | -} | |
| 106 | - | |
| 107 | -fn placeholder_auth_config() -> AuthConfig { | |
| 108 | - // Env vars not set at router-build time — use harmless defaults. | |
| 109 | - // Production callers should always go through `with_fediverse()` | |
| 110 | - // which loads `AuthConfig` once at app startup. | |
| 111 | - AuthConfig { | |
| 112 | - jwt_secret: "0".repeat(64), | |
| 113 | - jwt_expiration_minutes: 15, | |
| 114 | - jwt_issuer: "rustwarden".to_string(), | |
| 115 | - refresh_token_expiration_days: 7, | |
| 116 | - remember_me_expiration_days: 30, | |
| 117 | - session_timeout_minutes: 30, | |
| 118 | - session_backend: crate::config::auth::SessionBackend::Memory, | |
| 119 | - redis_url: None, | |
| 120 | - rate_limit_login_per_minute: 5, | |
| 121 | - rate_limit_refresh_per_minute: 10, | |
| 122 | - rate_limit_general_per_minute: 100, | |
| 123 | - cookie_secure: false, | |
| 124 | - cookie_same_site: "Strict".to_string(), | |
| 125 | - } | |
| 126 | -} | |
| 127 | - | |
| 128 | -/// Backwards-compatible alias for [`router_with`] kept until the next | |
| 129 | -/// major version; new code should call `router_with` directly. | |
| 130 | -#[deprecated(since = "0.2.0", note = "use `router_with` (RFC 0012)")] | |
| 131 | -pub fn router_with_resolver( | |
| 132 | - config: FediverseConfig, | |
| 133 | - resolver: Arc<dyn LocalUserResolver>, | |
| 134 | -) -> Router<DatabaseConnection> { | |
| 135 | - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::new()); | |
| 136 | - router_with(config, resolver, verifier) | |
| 137 | -} |
D
crates/rustwarden-core/src/fediverse/state.rs
+0
-28
@@ -1,28 +0,0 @@
| 1 | -//! Shared state injected into federation handlers via Axum extensions. | |
| 2 | - | |
| 3 | -use std::sync::Arc; | |
| 4 | - | |
| 5 | -use crate::config::FediverseConfig; | |
| 6 | - | |
| 7 | -use super::resolver::LocalUserResolver; | |
| 8 | - | |
| 9 | -/// Injected into every federation handler via `Extension<FediverseState>`. | |
| 10 | -/// Carries the runtime configuration and a handle to the user resolver. | |
| 11 | -/// | |
| 12 | -/// Cheap to clone — the resolver is behind `Arc`, the config is `Clone`. | |
| 13 | -#[derive(Clone)] | |
| 14 | -pub struct FediverseState { | |
| 15 | - /// Runtime configuration loaded by [`crate::app::RustwardenBuilder::with_fediverse`]. | |
| 16 | - pub config: FediverseConfig, | |
| 17 | - /// User store projection used by handlers. | |
| 18 | - pub resolver: Arc<dyn LocalUserResolver>, | |
| 19 | -} | |
| 20 | - | |
| 21 | -impl std::fmt::Debug for FediverseState { | |
| 22 | - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 23 | - f.debug_struct("FediverseState") | |
| 24 | - .field("config", &self.config) | |
| 25 | - .field("resolver", &"<dyn LocalUserResolver>") | |
| 26 | - .finish() | |
| 27 | - } | |
| 28 | -} |
D
crates/rustwarden-core/src/fediverse/types.rs
+0
-219
@@ -1,219 +0,0 @@
| 1 | -//! JSON types serialised on federation endpoints (Person, WebFinger JRD, | |
| 2 | -//! NodeInfo). All field names are stabilised to match the de facto Mastodon | |
| 3 | -//! / W3C ActivityStreams contracts. | |
| 4 | - | |
| 5 | -use serde::{Deserialize, Serialize}; | |
| 6 | -use url::Url; | |
| 7 | - | |
| 8 | -/// JSON-LD `@context` shared by every actor we publish. | |
| 9 | -/// | |
| 10 | -/// Includes: ActivityStreams 2.0 core vocabulary, W3C security vocabulary | |
| 11 | -/// (`publicKey` namespace), and Mastodon's `toot:` extension namespace | |
| 12 | -/// (covers `discoverable`, `indexable`, `Emoji`). | |
| 13 | -pub fn actor_context() -> serde_json::Value { | |
| 14 | - serde_json::json!([ | |
| 15 | - "https://www.w3.org/ns/activitystreams", | |
| 16 | - "https://w3id.org/security/v1", | |
| 17 | - { | |
| 18 | - "toot": "http://joinmastodon.org/ns#", | |
| 19 | - "discoverable": "toot:discoverable", | |
| 20 | - "indexable": "toot:indexable", | |
| 21 | - "Emoji": "toot:Emoji" | |
| 22 | - } | |
| 23 | - ]) | |
| 24 | -} | |
| 25 | - | |
| 26 | -/// ActivityPub Person actor (W3C ActivityStreams 2.0 + Security V1 + | |
| 27 | -/// Mastodon extensions). | |
| 28 | -/// | |
| 29 | -/// Field `public_key` is `None` until [RFC 0005](../../rfcs/0005-actor-key-storage.md) | |
| 30 | -/// (`actor-key-storage`) lands. Until then, the actor is **discoverable** but | |
| 31 | -/// not **federable** — Mastodon will refuse to deliver activities to an actor | |
| 32 | -/// without a published `publicKey`. | |
| 33 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 34 | -#[serde(rename_all = "camelCase")] | |
| 35 | -pub struct PersonJson { | |
| 36 | - /// JSON-LD context, see [`actor_context`]. | |
| 37 | - #[serde(rename = "@context")] | |
| 38 | - pub context: serde_json::Value, | |
| 39 | - /// Always the literal string `"Person"`. | |
| 40 | - #[serde(rename = "type")] | |
| 41 | - pub kind: String, | |
| 42 | - /// Canonical actor URL, e.g. `https://example.org/users/alice`. | |
| 43 | - pub id: Url, | |
| 44 | - /// Local username (the `alice` part of `@alice@example.org`). | |
| 45 | - pub preferred_username: String, | |
| 46 | - /// Optional display name shown in clients. | |
| 47 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 48 | - pub name: Option<String>, | |
| 49 | - /// HTML bio (sanitised before insertion into DB; rendered as-is here). | |
| 50 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 51 | - pub summary: Option<String>, | |
| 52 | - /// Profile page URL (HTML). | |
| 53 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 54 | - pub url: Option<Url>, | |
| 55 | - /// Inbox endpoint where remote servers POST activities for this actor. | |
| 56 | - pub inbox: Url, | |
| 57 | - /// Outbox endpoint listing this actor's published activities. | |
| 58 | - pub outbox: Url, | |
| 59 | - /// Followers collection URL. | |
| 60 | - pub followers: Url, | |
| 61 | - /// Following collection URL. | |
| 62 | - pub following: Url, | |
| 63 | - /// Mastodon-compatible shared inbox endpoint (instance-wide). | |
| 64 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 65 | - pub endpoints: Option<EndpointsJson>, | |
| 66 | - /// Public RSA key used to verify HTTP signatures. `None` until RFC 0005. | |
| 67 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 68 | - pub public_key: Option<PublicKeyJson>, | |
| 69 | - /// When `true`, follow requests await manual approval. | |
| 70 | - #[serde(default)] | |
| 71 | - pub manually_approves_followers: bool, | |
| 72 | - /// Mastodon `toot:discoverable` flag. | |
| 73 | - #[serde(default = "default_true")] | |
| 74 | - pub discoverable: bool, | |
| 75 | -} | |
| 76 | - | |
| 77 | -fn default_true() -> bool { | |
| 78 | - true | |
| 79 | -} | |
| 80 | - | |
| 81 | -/// Public RSA key embedded in a [`PersonJson`]. | |
| 82 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 83 | -#[serde(rename_all = "camelCase")] | |
| 84 | -pub struct PublicKeyJson { | |
| 85 | - /// Key identifier, conventionally `{actor_id}#main-key`. | |
| 86 | - pub id: Url, | |
| 87 | - /// URL of the actor that owns this key. | |
| 88 | - pub owner: Url, | |
| 89 | - /// PEM-encoded SubjectPublicKeyInfo (RSA-2048+). | |
| 90 | - pub public_key_pem: String, | |
| 91 | -} | |
| 92 | - | |
| 93 | -/// Mastodon-compatible `endpoints` object on a Person actor. | |
| 94 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 95 | -#[serde(rename_all = "camelCase")] | |
| 96 | -pub struct EndpointsJson { | |
| 97 | - /// Shared inbox URL (one per instance, used for fan-out optimisation). | |
| 98 | - pub shared_inbox: Url, | |
| 99 | -} | |
| 100 | - | |
| 101 | -// ───────────────────────── WebFinger (RFC 7033) ───────────────────────── | |
| 102 | - | |
| 103 | -/// WebFinger JRD response document. | |
| 104 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 105 | -pub struct WebFingerResponse { | |
| 106 | - /// Subject URI, must echo the `resource` query parameter. | |
| 107 | - pub subject: String, | |
| 108 | - /// Optional alternative URIs for the same subject. | |
| 109 | - #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 110 | - pub aliases: Vec<String>, | |
| 111 | - /// Typed links pointing at the various representations of the subject. | |
| 112 | - pub links: Vec<WebFingerLink>, | |
| 113 | -} | |
| 114 | - | |
| 115 | -/// Single typed link inside a [`WebFingerResponse`]. | |
| 116 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 117 | -pub struct WebFingerLink { | |
| 118 | - /// Link relation, e.g. `"self"` or | |
| 119 | - /// `"http://webfinger.net/rel/profile-page"`. | |
| 120 | - pub rel: String, | |
| 121 | - /// MIME type of the resource. | |
| 122 | - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] | |
| 123 | - pub media_type: Option<String>, | |
| 124 | - /// Concrete URL of the linked resource (mutually exclusive with | |
| 125 | - /// `template`). | |
| 126 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 127 | - pub href: Option<String>, | |
| 128 | - /// URI template (RFC 6570) when the relation is interaction-driven. | |
| 129 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 130 | - pub template: Option<String>, | |
| 131 | -} | |
| 132 | - | |
| 133 | -// ───────────────────────── NodeInfo (Diaspora) ────────────────────────── | |
| 134 | - | |
| 135 | -/// NodeInfo discovery document (`/.well-known/nodeinfo`). | |
| 136 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 137 | -pub struct NodeInfoDiscovery { | |
| 138 | - /// Links pointing at concrete NodeInfo schema versions. | |
| 139 | - pub links: Vec<NodeInfoLink>, | |
| 140 | -} | |
| 141 | - | |
| 142 | -/// Single link in a [`NodeInfoDiscovery`] document. | |
| 143 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 144 | -pub struct NodeInfoLink { | |
| 145 | - /// Schema relation, e.g. | |
| 146 | - /// `http://nodeinfo.diaspora.software/ns/schema/2.1`. | |
| 147 | - pub rel: String, | |
| 148 | - /// URL of the concrete NodeInfo resource. | |
| 149 | - pub href: String, | |
| 150 | -} | |
| 151 | - | |
| 152 | -/// NodeInfo 2.1 detail document (`/nodeinfo/2.1`). | |
| 153 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 154 | -#[serde(rename_all = "camelCase")] | |
| 155 | -pub struct NodeInfo { | |
| 156 | - /// Always `"2.1"`. | |
| 157 | - pub version: String, | |
| 158 | - /// Software name + version metadata. | |
| 159 | - pub software: NodeInfoSoftware, | |
| 160 | - /// Federation protocols supported, always at least `["activitypub"]`. | |
| 161 | - pub protocols: Vec<String>, | |
| 162 | - /// Inbound/outbound services bridged by this instance (empty for now). | |
| 163 | - pub services: NodeInfoServices, | |
| 164 | - /// Aggregated usage counters. | |
| 165 | - pub usage: NodeInfoUsage, | |
| 166 | - /// Whether new account registration is open to the public. | |
| 167 | - pub open_registrations: bool, | |
| 168 | - /// Free-form metadata (instance description, contact, …). | |
| 169 | - #[serde(default)] | |
| 170 | - pub metadata: serde_json::Value, | |
| 171 | -} | |
| 172 | - | |
| 173 | -/// Software identification block of a [`NodeInfo`]. | |
| 174 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 175 | -pub struct NodeInfoSoftware { | |
| 176 | - /// Lowercase software name, e.g. `"rustwarden"`. | |
| 177 | - pub name: String, | |
| 178 | - /// SemVer software version. | |
| 179 | - pub version: String, | |
| 180 | - /// Optional VCS repository URL. | |
| 181 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 182 | - pub repository: Option<String>, | |
| 183 | - /// Optional homepage URL. | |
| 184 | - #[serde(skip_serializing_if = "Option::is_none")] | |
| 185 | - pub homepage: Option<String>, | |
| 186 | -} | |
| 187 | - | |
| 188 | -/// Services bridge advertised by NodeInfo. | |
| 189 | -#[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 190 | -pub struct NodeInfoServices { | |
| 191 | - /// Inbound bridges (empty for Rustwarden). | |
| 192 | - #[serde(default)] | |
| 193 | - pub inbound: Vec<String>, | |
| 194 | - /// Outbound bridges (empty for Rustwarden). | |
| 195 | - #[serde(default)] | |
| 196 | - pub outbound: Vec<String>, | |
| 197 | -} | |
| 198 | - | |
| 199 | -/// Aggregated usage counters in [`NodeInfo`]. | |
| 200 | -#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 201 | -#[serde(rename_all = "camelCase")] | |
| 202 | -pub struct NodeInfoUsage { | |
| 203 | - /// User counters split by activity window. | |
| 204 | - pub users: NodeInfoUsageUsers, | |
| 205 | - /// Total number of locally-authored posts. | |
| 206 | - pub local_posts: u64, | |
| 207 | -} | |
| 208 | - | |
| 209 | -/// User-count split returned in [`NodeInfoUsage`]. | |
| 210 | -#[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 211 | -#[serde(rename_all = "camelCase")] | |
| 212 | -pub struct NodeInfoUsageUsers { | |
| 213 | - /// Total registered users. | |
| 214 | - pub total: u64, | |
| 215 | - /// Users active in the last 30 days. | |
| 216 | - pub active_month: u64, | |
| 217 | - /// Users active in the last 180 days. | |
| 218 | - pub active_halfyear: u64, | |
| 219 | -} |
D
crates/rustwarden-core/src/fediverse/webfinger.rs
+0
-280
@@ -1,280 +0,0 @@
| 1 | -//! WebFinger handler (RFC 7033). | |
| 2 | -//! | |
| 3 | -//! Resolves `@user@domain` handles to ActivityPub actor URLs. Mounted at | |
| 4 | -//! `GET /.well-known/webfinger`. | |
| 5 | - | |
| 6 | -use axum::{ | |
| 7 | - Extension, Json, | |
| 8 | - extract::Query, | |
| 9 | - http::{HeaderValue, StatusCode, header}, | |
| 10 | - response::{IntoResponse, Response}, | |
| 11 | -}; | |
| 12 | -use serde::Deserialize; | |
| 13 | - | |
| 14 | -use super::error::FediverseError; | |
| 15 | -use super::state::FediverseState; | |
| 16 | -use super::types::{WebFingerLink, WebFingerResponse}; | |
| 17 | - | |
| 18 | -/// Query parameters for the WebFinger handler. | |
| 19 | -#[derive(Debug, Deserialize)] | |
| 20 | -pub struct WebFingerQuery { | |
| 21 | - /// Handle being queried, e.g. `acct:alice@example.org`. | |
| 22 | - pub resource: String, | |
| 23 | -} | |
| 24 | - | |
| 25 | -/// `GET /.well-known/webfinger?resource=acct:user@domain`. | |
| 26 | -/// | |
| 27 | -/// Returns a JRD describing how to find the actor's ActivityPub | |
| 28 | -/// representation. Sets `Access-Control-Allow-Origin: *` (RFC 7033 §3.2). | |
| 29 | -pub async fn handle( | |
| 30 | - Query(query): Query<WebFingerQuery>, | |
| 31 | - Extension(state): Extension<FediverseState>, | |
| 32 | -) -> Result<Response, FediverseError> { | |
| 33 | - let (username, domain) = parse_acct_resource(&query.resource)?; | |
| 34 | - | |
| 35 | - if domain != state.config.domain() { | |
| 36 | - return Err(FediverseError::DomainMismatch); | |
| 37 | - } | |
| 38 | - | |
| 39 | - let user = state | |
| 40 | - .resolver | |
| 41 | - .find_local_user(&username) | |
| 42 | - .await? | |
| 43 | - .ok_or(FediverseError::UserNotFound)?; | |
| 44 | - | |
| 45 | - let actor_url = format!("{}users/{}", state.config.base_url(), user.username); | |
| 46 | - let profile_url = format!("{}@{}", state.config.base_url(), user.username); | |
| 47 | - | |
| 48 | - let body = WebFingerResponse { | |
| 49 | - subject: query.resource.clone(), | |
| 50 | - aliases: vec![actor_url.clone(), profile_url.clone()], | |
| 51 | - links: vec![ | |
| 52 | - WebFingerLink { | |
| 53 | - rel: "self".to_string(), | |
| 54 | - media_type: Some("application/activity+json".to_string()), | |
| 55 | - href: Some(actor_url), | |
| 56 | - template: None, | |
| 57 | - }, | |
| 58 | - WebFingerLink { | |
| 59 | - rel: "http://webfinger.net/rel/profile-page".to_string(), | |
| 60 | - media_type: Some("text/html".to_string()), | |
| 61 | - href: Some(profile_url), | |
| 62 | - template: None, | |
| 63 | - }, | |
| 64 | - ], | |
| 65 | - }; | |
| 66 | - | |
| 67 | - Ok(( | |
| 68 | - StatusCode::OK, | |
| 69 | - [ | |
| 70 | - ( | |
| 71 | - header::CONTENT_TYPE, | |
| 72 | - HeaderValue::from_static("application/jrd+json"), | |
| 73 | - ), | |
| 74 | - ( | |
| 75 | - header::ACCESS_CONTROL_ALLOW_ORIGIN, | |
| 76 | - HeaderValue::from_static("*"), | |
| 77 | - ), | |
| 78 | - ], | |
| 79 | - Json(body), | |
| 80 | - ) | |
| 81 | - .into_response()) | |
| 82 | -} | |
| 83 | - | |
| 84 | -/// Parse a `resource=acct:user@domain` value into `(username, domain)`. | |
| 85 | -/// | |
| 86 | -/// Returns [`FediverseError::InvalidResource`] if the prefix is missing or | |
| 87 | -/// the `@` separator is absent or any side is empty. | |
| 88 | -pub(crate) fn parse_acct_resource(resource: &str) -> Result<(String, String), FediverseError> { | |
| 89 | - let acct = resource | |
| 90 | - .strip_prefix("acct:") | |
| 91 | - .ok_or_else(|| FediverseError::InvalidResource(format!("missing acct: prefix in {resource}")))?; | |
| 92 | - | |
| 93 | - let (user, domain) = acct.split_once('@').ok_or_else(|| { | |
| 94 | - FediverseError::InvalidResource(format!("missing @ separator in {resource}")) | |
| 95 | - })?; | |
| 96 | - | |
| 97 | - if user.is_empty() || domain.is_empty() { | |
| 98 | - return Err(FediverseError::InvalidResource(format!( | |
| 99 | - "empty user or domain in {resource}" | |
| 100 | - ))); | |
| 101 | - } | |
| 102 | - | |
| 103 | - Ok((user.to_string(), domain.to_string())) | |
| 104 | -} | |
| 105 | - | |
| 106 | -#[cfg(test)] | |
| 107 | -mod tests { | |
| 108 | - use super::*; | |
| 109 | - use crate::config::FediverseConfig; | |
| 110 | - use crate::fediverse::resolver::{LocalUserResolver, UserSummary}; | |
| 111 | - use async_trait::async_trait; | |
| 112 | - use axum::Router; | |
| 113 | - use axum::body::Body; | |
| 114 | - use axum::http::Request; | |
| 115 | - use axum::routing::get; | |
| 116 | - use std::sync::Arc; | |
| 117 | - use tower::ServiceExt; | |
| 118 | - use uuid::Uuid; | |
| 119 | - | |
| 120 | - /// Stub resolver returning predefined users. | |
| 121 | - struct StubResolver { | |
| 122 | - user: Option<UserSummary>, | |
| 123 | - } | |
| 124 | - | |
| 125 | - #[async_trait] | |
| 126 | - impl LocalUserResolver for StubResolver { | |
| 127 | - async fn find_local_user( | |
| 128 | - &self, | |
| 129 | - _username: &str, | |
| 130 | - ) -> Result<Option<UserSummary>, crate::error::AppError> { | |
| 131 | - Ok(self.user.clone()) | |
| 132 | - } | |
| 133 | - async fn count_users_total(&self) -> Result<u64, crate::error::AppError> { | |
| 134 | - Ok(0) | |
| 135 | - } | |
| 136 | - async fn count_users_active_since(&self, _days: u32) -> Result<u64, crate::error::AppError> { | |
| 137 | - Ok(0) | |
| 138 | - } | |
| 139 | - async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> { | |
| 140 | - Ok(0) | |
| 141 | - } | |
| 142 | - } | |
| 143 | - | |
| 144 | - fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router { | |
| 145 | - let state = FediverseState { | |
| 146 | - config: FediverseConfig::for_tests(domain), | |
| 147 | - resolver: Arc::new(StubResolver { user }), | |
| 148 | - }; | |
| 149 | - Router::new() | |
| 150 | - .route("/.well-known/webfinger", get(handle)) | |
| 151 | - .layer(Extension(state)) | |
| 152 | - } | |
| 153 | - | |
| 154 | - fn alice() -> UserSummary { | |
| 155 | - UserSummary { | |
| 156 | - id: Uuid::new_v4(), | |
| 157 | - username: "alice".into(), | |
| 158 | - display_name: Some("Alice".into()), | |
| 159 | - bio: None, | |
| 160 | - ap_public_key_pem: None, | |
| 161 | - } | |
| 162 | - } | |
| 163 | - | |
| 164 | - async fn body_json(response: Response) -> serde_json::Value { | |
| 165 | - let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 166 | - .await | |
| 167 | - .expect("body bytes"); | |
| 168 | - serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 169 | - } | |
| 170 | - | |
| 171 | - #[tokio::test] | |
| 172 | - async fn webfinger_returns_jrd_for_existing_local_user() { | |
| 173 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 174 | - | |
| 175 | - let response = app | |
| 176 | - .oneshot( | |
| 177 | - Request::builder() | |
| 178 | - .uri("/.well-known/webfinger?resource=acct:alice@example.org") | |
| 179 | - .body(Body::empty()) | |
| 180 | - .unwrap(), | |
| 181 | - ) | |
| 182 | - .await | |
| 183 | - .unwrap(); | |
| 184 | - | |
| 185 | - assert_eq!(response.status(), StatusCode::OK); | |
| 186 | - assert_eq!( | |
| 187 | - response | |
| 188 | - .headers() | |
| 189 | - .get(header::CONTENT_TYPE) | |
| 190 | - .map(HeaderValue::to_str) | |
| 191 | - .transpose() | |
| 192 | - .unwrap() | |
| 193 | - .unwrap_or_default(), | |
| 194 | - "application/jrd+json" | |
| 195 | - ); | |
| 196 | - assert_eq!( | |
| 197 | - response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), | |
| 198 | - Some(&HeaderValue::from_static("*")) | |
| 199 | - ); | |
| 200 | - | |
| 201 | - let body = body_json(response).await; | |
| 202 | - assert_eq!(body["subject"], "acct:alice@example.org"); | |
| 203 | - let self_link = body["links"] | |
| 204 | - .as_array() | |
| 205 | - .expect("links array") | |
| 206 | - .iter() | |
| 207 | - .find(|l| l["rel"] == "self") | |
| 208 | - .expect("self link present"); | |
| 209 | - assert_eq!(self_link["type"], "application/activity+json"); | |
| 210 | - assert_eq!(self_link["href"], "https://example.org/users/alice"); | |
| 211 | - } | |
| 212 | - | |
| 213 | - #[tokio::test] | |
| 214 | - async fn webfinger_returns_404_for_unknown_user() { | |
| 215 | - let app = router_with_resolver("example.org", None); | |
| 216 | - | |
| 217 | - let response = app | |
| 218 | - .oneshot( | |
| 219 | - Request::builder() | |
| 220 | - .uri("/.well-known/webfinger?resource=acct:bob@example.org") | |
| 221 | - .body(Body::empty()) | |
| 222 | - .unwrap(), | |
| 223 | - ) | |
| 224 | - .await | |
| 225 | - .unwrap(); | |
| 226 | - | |
| 227 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 228 | - } | |
| 229 | - | |
| 230 | - #[tokio::test] | |
| 231 | - async fn webfinger_returns_404_when_domain_mismatches() { | |
| 232 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 233 | - | |
| 234 | - let response = app | |
| 235 | - .oneshot( | |
| 236 | - Request::builder() | |
| 237 | - .uri("/.well-known/webfinger?resource=acct:alice@other.tld") | |
| 238 | - .body(Body::empty()) | |
| 239 | - .unwrap(), | |
| 240 | - ) | |
| 241 | - .await | |
| 242 | - .unwrap(); | |
| 243 | - | |
| 244 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 245 | - } | |
| 246 | - | |
| 247 | - #[tokio::test] | |
| 248 | - async fn webfinger_returns_400_for_malformed_resource() { | |
| 249 | - let app = router_with_resolver("example.org", Some(alice())); | |
| 250 | - | |
| 251 | - for bad in [ | |
| 252 | - "/.well-known/webfinger?resource=alice@example.org", | |
| 253 | - "/.well-known/webfinger?resource=acct:noatdomain", | |
| 254 | - "/.well-known/webfinger?resource=acct:", | |
| 255 | - ] { | |
| 256 | - let response = app | |
| 257 | - .clone() | |
| 258 | - .oneshot(Request::builder().uri(bad).body(Body::empty()).unwrap()) | |
| 259 | - .await | |
| 260 | - .unwrap(); | |
| 261 | - assert_eq!(response.status(), StatusCode::BAD_REQUEST, "bad uri={bad}"); | |
| 262 | - } | |
| 263 | - } | |
| 264 | - | |
| 265 | - #[test] | |
| 266 | - fn parse_acct_resource_accepts_valid_handle() { | |
| 267 | - let (user, domain) = parse_acct_resource("acct:alice@example.org").expect("valid"); | |
| 268 | - assert_eq!(user, "alice"); | |
| 269 | - assert_eq!(domain, "example.org"); | |
| 270 | - } | |
| 271 | - | |
| 272 | - #[test] | |
| 273 | - fn parse_acct_resource_rejects_missing_prefix() { | |
| 274 | - let err = parse_acct_resource("alice@example.org").expect_err("must fail"); | |
| 275 | - assert!( | |
| 276 | - matches!(err, FediverseError::InvalidResource(_)), | |
| 277 | - "got {err:?}" | |
| 278 | - ); | |
| 279 | - } | |
| 280 | -} |
M
crates/rustwarden-core/src/lib.rs
+7
-5
@@ -11,11 +11,13 @@ pub mod auth;
| 11 | 11 | pub mod config; |
| 12 | 12 | /// Application error types and HTTP response conversion. |
| 13 | 13 | pub mod error; |
| 14 | -/// ActivityPub identity federation (RFC 0001+, gated by `experimental-fediverse`). | |
| 15 | -#[cfg(feature = "experimental-fediverse")] | |
| 16 | -pub mod fediverse; | |
| 17 | -/// Unified actor abstraction (RFC 0008, gated by `experimental-fediverse`). | |
| 18 | -#[cfg(feature = "experimental-fediverse")] | |
| 14 | +/// Unified actor abstraction (RFC 0008). | |
| 15 | +/// | |
| 16 | +/// `Identity` and `IdentityResolver` stay in core (no federation dep) | |
| 17 | +/// because the framework's auth middleware, RBAC, and resource services | |
| 18 | +/// must distinguish local vs. remote callers even when the | |
| 19 | +/// `rustwarden-fediverse` crate is not linked. ActivityPub federation | |
| 20 | +/// itself moved to `rustwarden-fediverse` (RFC 0001+). | |
| 19 | 21 | pub mod identity; |
| 20 | 22 | /// Extension hooks for consumer applications to react to framework events. |
| 21 | 23 | pub mod hooks; |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+47
-48
@@ -10,6 +10,7 @@ use sea_orm::DatabaseConnection;
| 10 | 10 | use std::{ |
| 11 | 11 | future::Future, |
| 12 | 12 | pin::Pin, |
| 13 | + sync::Arc, | |
| 13 | 14 | task::{Context, Poll}, |
| 14 | 15 | }; |
| 15 | 16 | use tower::Service; |
@@ -31,12 +32,29 @@ pub(crate) fn sanitize_redirect(uri: &str) -> String {
| 31 | 32 | pub struct AuthService<S> { |
| 32 | 33 | inner: S, |
| 33 | 34 | db: DatabaseConnection, |
| 35 | + protected_prefixes: Arc<Vec<String>>, | |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | 38 | impl<S> AuthService<S> { |
| 37 | 39 | /// Create a new `AuthService` middleware wrapping the given inner service. |
| 38 | - pub fn new(inner: S, db: DatabaseConnection) -> Self { | |
| 39 | - Self { inner, db } | |
| 40 | + /// | |
| 41 | + /// `protected_prefixes` is the list of URL path prefixes for which the | |
| 42 | + /// middleware enforces JWT authentication. Filled in by | |
| 43 | + /// [`crate::app::RustwardenBuilder::register_protected_prefix`] (and | |
| 44 | + /// the workspace defaults `/home`, `/profile`, …). Companion crates | |
| 45 | + /// such as `rustwarden-fediverse` register their own prefixes | |
| 46 | + /// (`/fediverse/consent`, `/fediverse/invite`) via the builder before | |
| 47 | + /// the app boots. | |
| 48 | + pub fn new( | |
| 49 | + inner: S, | |
| 50 | + db: DatabaseConnection, | |
| 51 | + protected_prefixes: Arc<Vec<String>>, | |
| 52 | + ) -> Self { | |
| 53 | + Self { | |
| 54 | + inner, | |
| 55 | + db, | |
| 56 | + protected_prefixes, | |
| 57 | + } | |
| 40 | 58 | } |
| 41 | 59 | } |
| 42 | 60 |
@@ -58,21 +76,6 @@ where
| 58 | 76 | let uri = req.uri().clone(); |
| 59 | 77 | let path = uri.path().to_owned(); |
| 60 | 78 | |
| 61 | - // RFC 0023 Slice B iter. 4: `/fediverse/consent/{id}` is a | |
| 62 | - // logged-in-only IdP-side consent screen ; the prefix gates | |
| 63 | - // injection of `Claims` so the handler can read who is approving. | |
| 64 | - // RFC 0024 Slice C iter. 6: `/fediverse/invite/{id}` is the | |
| 65 | - // logged-in-only landing page for a cross-instance team | |
| 66 | - // invitation, used by the invitee (typically a remote identity). | |
| 67 | - let protected_routes = [ | |
| 68 | - "/home", | |
| 69 | - "/profile", | |
| 70 | - "/test-base", | |
| 71 | - "/settings", | |
| 72 | - "/admin", | |
| 73 | - "/fediverse/consent", | |
| 74 | - "/fediverse/invite", | |
| 75 | - ]; | |
| 76 | 79 | // NOTE on `/api/v1/teams/*` : *not* protected here on purpose. |
| 77 | 80 | // The canonical admin path is the UI handler |
| 78 | 81 | // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite` |
@@ -84,7 +87,10 @@ where
| 84 | 87 | // present. A future RFC will gate this route once external |
| 85 | 88 | // consumers are wired through a per-route auth extractor |
| 86 | 89 | // independent of the UI middleware. |
| 87 | - let is_protected = protected_routes.iter().any(|route| path.starts_with(route)); | |
| 90 | + let is_protected = self | |
| 91 | + .protected_prefixes | |
| 92 | + .iter() | |
| 93 | + .any(|prefix| path.starts_with(prefix.as_str())); | |
| 88 | 94 | |
| 89 | 95 | if is_protected { |
| 90 | 96 | let db = self.db.clone(); |
@@ -158,39 +164,32 @@ where
| 158 | 164 | } |
| 159 | 165 | }; |
| 160 | 166 | |
| 161 | - // F10 (iter 4 / S4) — remote JWTs (`kind=remote`) must be | |
| 162 | - // rejected when their backing `remote_identities` row was | |
| 163 | - // disabled (kill_domain, F9 auto-kill, or manual revoke). | |
| 164 | - // Feature-gated because `remote_identity` model only | |
| 165 | - // exists when `experimental-fediverse` is on; without the | |
| 166 | - // feature, remote JWTs simply can't be minted, so the | |
| 167 | - // check is unreachable. | |
| 168 | - #[cfg(feature = "experimental-fediverse")] | |
| 167 | + // F10 (RFC 0018) — refuse remote JWTs whose backing | |
| 168 | + // `remote_identities` row was disabled (kill_domain, | |
| 169 | + // auto-kill, manual revoke). The check delegates to a | |
| 170 | + // `RemoteIdentityRevocationChecker` injected by | |
| 171 | + // `rustwarden-fediverse` as an Axum `Extension`. When the | |
| 172 | + // federation crate isn't linked the extension is absent | |
| 173 | + // and the branch is skipped — which is correct, because | |
| 174 | + // remote JWT issuance is also gated by that crate. | |
| 169 | 175 | if claims.is_remote() { |
| 170 | 176 | if let Some(rid) = claims.remote_identity_id() { |
| 171 | - use crate::models::remote_identity; | |
| 172 | - use sea_orm::EntityTrait; | |
| 173 | - match remote_identity::Entity::find_by_id(rid).one(&db).await { | |
| 174 | - Ok(Some(ri)) => { | |
| 175 | - if ri.disabled_at.is_some() { | |
| 176 | - tracing::warn!( | |
| 177 | - "AuthService: remote_identity {} is disabled, rejecting JWT", | |
| 178 | - rid | |
| 179 | - ); | |
| 180 | - let redirect_url = sanitize_redirect(uri.path()); | |
| 181 | - return Ok( | |
| 182 | - axum::response::Redirect::to(&redirect_url).into_response() | |
| 183 | - ); | |
| 184 | - } | |
| 177 | + if let Some(checker) = parts | |
| 178 | + .extensions | |
| 179 | + .get::<std::sync::Arc< | |
| 180 | + dyn crate::middleware::revocation::RemoteIdentityRevocationChecker, | |
| 181 | + >>() | |
| 182 | + { | |
| 183 | + if checker.is_revoked(rid).await { | |
| 184 | + tracing::warn!( | |
| 185 | + "AuthService: remote_identity {} is revoked, rejecting JWT", | |
| 186 | + rid | |
| 187 | + ); | |
| 188 | + let redirect_url = sanitize_redirect(uri.path()); | |
| 189 | + return Ok( | |
| 190 | + axum::response::Redirect::to(&redirect_url).into_response() | |
| 191 | + ); | |
| 185 | 192 | } |
| 186 | - Ok(None) => tracing::warn!( | |
| 187 | - "AuthService: F10 — remote_identity {} not found", | |
| 188 | - rid | |
| 189 | - ), | |
| 190 | - Err(e) => tracing::warn!( | |
| 191 | - "AuthService: F10 — remote_identity lookup error: {}", | |
| 192 | - e | |
| 193 | - ), | |
| 194 | 193 | } |
| 195 | 194 | } |
| 196 | 195 | } |
M
crates/rustwarden-core/src/middleware/mod.rs
+3
-0
@@ -15,6 +15,9 @@ pub mod auth_required;
| 15 | 15 | pub mod auth_service; |
| 16 | 16 | /// CSRF token generation and validation middleware. |
| 17 | 17 | pub mod csrf; |
| 18 | +/// Trait consulted by `AuthService` to refuse revoked remote JWTs | |
| 19 | +/// (implemented and injected by `rustwarden-fediverse`). | |
| 20 | +pub mod revocation; | |
| 18 | 21 | /// Centralized error handling middleware that maps errors to structured responses. |
| 19 | 22 | pub mod error_handler; |
| 20 | 23 | /// Middleware that validates password complexity requirements. |
A
crates/rustwarden-core/src/middleware/revocation.rs
+36
-0
@@ -0,0 +1,36 @@
| 1 | +//! Remote identity revocation hook (RFC 0018 / F10). | |
| 2 | +//! | |
| 3 | +//! When a federated user's `remote_identities` row is disabled (manual | |
| 4 | +//! revoke, `kill_domain`, or auto-kill on signature failure), any | |
| 5 | +//! outstanding remote JWT minted for that identity must be rejected at | |
| 6 | +//! the next middleware pass. The check used to live directly in | |
| 7 | +//! `AuthService`, but doing so required `rustwarden-core` to know the | |
| 8 | +//! `remote_identity` SeaORM entity — which now lives in | |
| 9 | +//! `rustwarden-fediverse`. To keep the dependency arrow strictly | |
| 10 | +//! `fediverse → core` (no cycle), `core` only exposes this trait; the | |
| 11 | +//! federation crate provides a SeaORM-backed implementation and | |
| 12 | +//! injects it as a Tower `Extension` when it mounts its routes. | |
| 13 | +//! | |
| 14 | +//! Consumers that don't link `rustwarden-fediverse` simply don't | |
| 15 | +//! register the extension: `AuthService` then skips the check, which | |
| 16 | +//! is the correct behaviour because remote JWTs can only be minted by | |
| 17 | +//! the federation crate in the first place. | |
| 18 | + | |
| 19 | +use async_trait::async_trait; | |
| 20 | +use uuid::Uuid; | |
| 21 | + | |
| 22 | +/// Predicate consulted by `AuthService` to decide whether a remote JWT | |
| 23 | +/// must be refused because its backing `remote_identities` row has | |
| 24 | +/// been disabled. | |
| 25 | +/// | |
| 26 | +/// `is_revoked` returns `true` when the JWT must be refused. Network | |
| 27 | +/// or database errors should map to `false` (fail-open) so a transient | |
| 28 | +/// outage doesn't lock every federated user out: the worst case is a | |
| 29 | +/// disabled identity getting one more request through, which the next | |
| 30 | +/// poll will catch. | |
| 31 | +#[async_trait] | |
| 32 | +pub trait RemoteIdentityRevocationChecker: Send + Sync { | |
| 33 | + /// Return `true` if the remote identity has been disabled and the | |
| 34 | + /// JWT must be refused. | |
| 35 | + async fn is_revoked(&self, remote_identity_id: Uuid) -> bool; | |
| 36 | +} |
D
crates/rustwarden-core/src/models/fediverse_auth_challenge.rs
+0
-46
@@ -1,46 +0,0 @@
| 1 | -//! SeaORM entity for `fediverse_auth_challenges` (RFC 0006). | |
| 2 | -//! | |
| 3 | -//! State machine row backing the AP-native authentication flow on the | |
| 4 | -//! client (`b.tld`) side. | |
| 5 | - | |
| 6 | -use chrono::{DateTime, Utc}; | |
| 7 | -use sea_orm::entity::prelude::*; | |
| 8 | -use serde::{Deserialize, Serialize}; | |
| 9 | -use uuid::Uuid; | |
| 10 | - | |
| 11 | -/// Persistent record of one in-flight authentication challenge. | |
| 12 | -#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | -#[sea_orm(table_name = "fediverse_auth_challenges")] | |
| 14 | -pub struct Model { | |
| 15 | - /// Stable PK. | |
| 16 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | - pub id: Uuid, | |
| 18 | - /// 256-bit URL-safe base64 nonce embedded in the AP `AuthRequest` | |
| 19 | - /// activity. Must match the nonce echoed by the IdP `Accept`. | |
| 20 | - #[sea_orm(unique)] | |
| 21 | - pub nonce: String, | |
| 22 | - /// `@user@domain` handle the user typed (verbatim). | |
| 23 | - pub handle: String, | |
| 24 | - /// Domain part of the handle (lowercased). | |
| 25 | - pub domain: String, | |
| 26 | - /// Optional FK to `remote_identities.id` once the IdP has approved | |
| 27 | - /// the challenge. | |
| 28 | - pub requested_remote_id: Option<Uuid>, | |
| 29 | - /// `pending`, `approved`, `rejected`, `expired`, or `consumed`. | |
| 30 | - pub status: String, | |
| 31 | - /// Where to redirect the browser after a successful approve. | |
| 32 | - pub redirect_url: Option<String>, | |
| 33 | - /// Insertion timestamp. | |
| 34 | - pub created_at: DateTime<Utc>, | |
| 35 | - /// Hard deadline (`created_at + ttl`) after which the row is | |
| 36 | - /// considered `Expired` regardless of status. | |
| 37 | - pub expires_at: DateTime<Utc>, | |
| 38 | - /// Set the moment a JWT is minted from this challenge (one-shot). | |
| 39 | - pub consumed_at: Option<DateTime<Utc>>, | |
| 40 | -} | |
| 41 | - | |
| 42 | -/// SeaORM relations (none modelled here). | |
| 43 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 44 | -pub enum Relation {} | |
| 45 | - | |
| 46 | -impl ActiveModelBehavior for ActiveModel {} |
D
crates/rustwarden-core/src/models/fediverse_blocked_domain.rs
+0
-35
@@ -1,35 +0,0 @@
| 1 | -//! `fediverse_blocked_domains` SeaORM entity (RFC 0004). | |
| 2 | -//! | |
| 3 | -//! Blocklist of remote Fediverse domains explicitly refused by the local | |
| 4 | -//! admin. Used when `app_settings.fediverse_trust_mode = "open_with_blocklist"`. | |
| 5 | - | |
| 6 | -use chrono::{DateTime, Utc}; | |
| 7 | -use sea_orm::entity::prelude::*; | |
| 8 | -use serde::{Deserialize, Serialize}; | |
| 9 | -use uuid::Uuid; | |
| 10 | - | |
| 11 | -/// `fediverse_blocked_domains` row. | |
| 12 | -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | -#[sea_orm(table_name = "fediverse_blocked_domains")] | |
| 14 | -pub struct Model { | |
| 15 | - /// Primary key. | |
| 16 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | - pub id: Uuid, | |
| 18 | - /// Bare domain name, lowercased and stripped of `www.` prefix. | |
| 19 | - #[sea_orm(unique)] | |
| 20 | - pub domain: String, | |
| 21 | - /// `"suspend"`, `"silence"`, or `"reject_auth"`. | |
| 22 | - pub severity: String, | |
| 23 | - /// Optional admin reason for blocking. | |
| 24 | - pub reason: Option<String>, | |
| 25 | - /// User that added the entry. | |
| 26 | - pub added_by_user_id: Option<Uuid>, | |
| 27 | - /// Insertion timestamp. | |
| 28 | - pub created_at: DateTime<Utc>, | |
| 29 | -} | |
| 30 | - | |
| 31 | -/// SeaORM relations (none). | |
| 32 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 33 | -pub enum Relation {} | |
| 34 | - | |
| 35 | -impl ActiveModelBehavior for ActiveModel {} |
D
crates/rustwarden-core/src/models/fediverse_outbound_queue.rs
+0
-51
@@ -1,51 +0,0 @@
| 1 | -//! SeaORM entity for the `fediverse_outbound_queue` table (RFC 0011). | |
| 2 | -//! | |
| 3 | -//! Persistent journal of ActivityPub activities that must be delivered | |
| 4 | -//! to remote inboxes by [`crate::services::fediverse_outbound_service::OutboundWorker`]. | |
| 5 | - | |
| 6 | -use chrono::{DateTime, Utc}; | |
| 7 | -use sea_orm::entity::prelude::*; | |
| 8 | -use serde::{Deserialize, Serialize}; | |
| 9 | -use uuid::Uuid; | |
| 10 | - | |
| 11 | -/// Persistent record of one outbound delivery (one-to-one with the | |
| 12 | -/// `fediverse_outbound_queue` row). | |
| 13 | -#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 14 | -#[sea_orm(table_name = "fediverse_outbound_queue")] | |
| 15 | -pub struct Model { | |
| 16 | - /// Stable PK. | |
| 17 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 18 | - pub id: Uuid, | |
| 19 | - /// Canonical AP `id` of the activity being delivered. | |
| 20 | - pub activity_id: String, | |
| 21 | - /// Pre-rendered JSON-LD body posted to `target_inbox`. | |
| 22 | - pub payload_json: String, | |
| 23 | - /// Remote inbox URL. | |
| 24 | - pub target_inbox: String, | |
| 25 | - /// Domain the inbox belongs to (used for trust checks). | |
| 26 | - pub target_domain: String, | |
| 27 | - /// `pending`, `delivered`, `failed`, or `dead`. | |
| 28 | - pub status: String, | |
| 29 | - /// Number of delivery attempts already performed. | |
| 30 | - pub attempts: i32, | |
| 31 | - /// Maximum attempts before the row is moved to `dead`. | |
| 32 | - pub max_attempts: i32, | |
| 33 | - /// Last delivery error (truncated). | |
| 34 | - pub last_error: Option<String>, | |
| 35 | - /// Earliest moment the row is eligible for re-delivery. | |
| 36 | - pub next_retry_at: Option<DateTime<Utc>>, | |
| 37 | - /// Insertion timestamp. | |
| 38 | - pub created_at: DateTime<Utc>, | |
| 39 | - /// Successful delivery timestamp. | |
| 40 | - pub delivered_at: Option<DateTime<Utc>>, | |
| 41 | - /// Local user the activity is sent on behalf of (used for HTTP-Sig | |
| 42 | - /// signing in RFC 0010; nullable while only `NoopDelivery` is | |
| 43 | - /// shipped). | |
| 44 | - pub actor_user_id: Option<Uuid>, | |
| 45 | -} | |
| 46 | - | |
| 47 | -/// SeaORM relations (none for this table). | |
| 48 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 49 | -pub enum Relation {} | |
| 50 | - | |
| 51 | -impl ActiveModelBehavior for ActiveModel {} |
D
crates/rustwarden-core/src/models/fediverse_pending_invite.rs
+0
-49
@@ -1,49 +0,0 @@
| 1 | -//! SeaORM entity for `fediverse_pending_invites` (RFC 0009). | |
| 2 | -//! | |
| 3 | -//! Persistent record of cross-instance team-membership invitations | |
| 4 | -//! issued by a Rustwarden host (`b.tld`) towards a remote user | |
| 5 | -//! (`alice@a.tld`). The activity reference (`activity_id`) lets the | |
| 6 | -//! AP `Accept{Invite}` / `Reject{Invite}` callbacks find the right | |
| 7 | -//! row without ambiguity. | |
| 8 | - | |
| 9 | -use chrono::{DateTime, Utc}; | |
| 10 | -use sea_orm::entity::prelude::*; | |
| 11 | -use serde::{Deserialize, Serialize}; | |
| 12 | -use uuid::Uuid; | |
| 13 | - | |
| 14 | -/// Persistent record of one in-flight invitation. | |
| 15 | -#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 16 | -#[sea_orm(table_name = "fediverse_pending_invites")] | |
| 17 | -pub struct Model { | |
| 18 | - /// Stable PK. | |
| 19 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 20 | - pub id: Uuid, | |
| 21 | - /// AP `Invite` activity id (e.g. `https://b.tld/invites/<uuid>`). | |
| 22 | - #[sea_orm(unique)] | |
| 23 | - pub activity_id: String, | |
| 24 | - /// FK to `teams.id`. | |
| 25 | - pub team_id: Uuid, | |
| 26 | - /// `@user@domain` handle invited. | |
| 27 | - pub invited_handle: String, | |
| 28 | - /// FK to `remote_identities.id`, populated when the AP `Accept` | |
| 29 | - /// arrives and the remote actor is fully resolved. | |
| 30 | - pub invited_remote_identity_id: Option<Uuid>, | |
| 31 | - /// Team role to assign on accept (`"member"`, `"admin"`). | |
| 32 | - pub role: String, | |
| 33 | - /// FK to `users.id` of the inviter. | |
| 34 | - pub invited_by_user_id: Uuid, | |
| 35 | - /// `pending`, `accepted`, `rejected`, `revoked`, or `expired`. | |
| 36 | - pub status: String, | |
| 37 | - /// Insertion timestamp. | |
| 38 | - pub created_at: DateTime<Utc>, | |
| 39 | - /// Hard deadline. | |
| 40 | - pub expires_at: DateTime<Utc>, | |
| 41 | - /// Set when the invite reaches a terminal state. | |
| 42 | - pub decided_at: Option<DateTime<Utc>>, | |
| 43 | -} | |
| 44 | - | |
| 45 | -/// SeaORM relations (none modelled here). | |
| 46 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 47 | -pub enum Relation {} | |
| 48 | - | |
| 49 | -impl ActiveModelBehavior for ActiveModel {} |
D
crates/rustwarden-core/src/models/fediverse_trusted_domain.rs
+0
-33
@@ -1,33 +0,0 @@
| 1 | -//! `fediverse_trusted_domains` SeaORM entity (RFC 0004). | |
| 2 | -//! | |
| 3 | -//! Allowlist of remote Fediverse domains explicitly trusted by the local | |
| 4 | -//! admin. Used when `app_settings.fediverse_trust_mode = "allowlist"`. | |
| 5 | - | |
| 6 | -use chrono::{DateTime, Utc}; | |
| 7 | -use sea_orm::entity::prelude::*; | |
| 8 | -use serde::{Deserialize, Serialize}; | |
| 9 | -use uuid::Uuid; | |
| 10 | - | |
| 11 | -/// `fediverse_trusted_domains` row. | |
| 12 | -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | -#[sea_orm(table_name = "fediverse_trusted_domains")] | |
| 14 | -pub struct Model { | |
| 15 | - /// Primary key. | |
| 16 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | - pub id: Uuid, | |
| 18 | - /// Bare domain name, lowercased and stripped of `www.` prefix. | |
| 19 | - #[sea_orm(unique)] | |
| 20 | - pub domain: String, | |
| 21 | - /// Optional admin note (motivation for the trust). | |
| 22 | - pub note: Option<String>, | |
| 23 | - /// User that added the entry (NULL for bootstrap / system insertion). | |
| 24 | - pub added_by_user_id: Option<Uuid>, | |
| 25 | - /// Insertion timestamp. | |
| 26 | - pub created_at: DateTime<Utc>, | |
| 27 | -} | |
| 28 | - | |
| 29 | -/// SeaORM relations (none). | |
| 30 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 31 | -pub enum Relation {} | |
| 32 | - | |
| 33 | -impl ActiveModelBehavior for ActiveModel {} |
M
crates/rustwarden-core/src/models/mod.rs
+3
-27
@@ -27,27 +27,9 @@ pub mod permission;
| 27 | 27 | pub mod personal_access_token; |
| 28 | 28 | /// Refresh token entity for JWT rotation. |
| 29 | 29 | pub mod refresh_token; |
| 30 | -/// Remote ActivityPub identity cache (RFC 0003, gated by `experimental-fediverse`). | |
| 31 | -#[cfg(feature = "experimental-fediverse")] | |
| 32 | -pub mod remote_identity; | |
| 33 | -/// Federation blocklist (RFC 0004, gated by `experimental-fediverse`). | |
| 34 | -#[cfg(feature = "experimental-fediverse")] | |
| 35 | -pub mod fediverse_blocked_domain; | |
| 36 | -/// Federation allowlist (RFC 0004, gated by `experimental-fediverse`). | |
| 37 | -#[cfg(feature = "experimental-fediverse")] | |
| 38 | -pub mod fediverse_trusted_domain; | |
| 39 | -/// Persistent outbound delivery queue (RFC 0011, | |
| 40 | -/// gated by `experimental-fediverse`). | |
| 41 | -#[cfg(feature = "experimental-fediverse")] | |
| 42 | -pub mod fediverse_outbound_queue; | |
| 43 | -/// AP-native authentication challenge (RFC 0006, | |
| 44 | -/// gated by `experimental-fediverse`). | |
| 45 | -#[cfg(feature = "experimental-fediverse")] | |
| 46 | -pub mod fediverse_auth_challenge; | |
| 47 | -/// Cross-instance team-membership invitation (RFC 0009, | |
| 48 | -/// gated by `experimental-fediverse`). | |
| 49 | -#[cfg(feature = "experimental-fediverse")] | |
| 50 | -pub mod fediverse_pending_invite; | |
| 30 | +// Federation entities (`remote_identity`, `fediverse_*`) now live in the | |
| 31 | +// `rustwarden-fediverse` crate. Consumers must import them via | |
| 32 | +// `rustwarden_fediverse::models::*`. | |
| 51 | 33 | /// Generic resource registry entity. |
| 52 | 34 | pub mod resource; |
| 53 | 35 | /// Resource share entity granting access to a principal. |
@@ -84,12 +66,6 @@ pub use password_reset_token::Entity as PasswordResetToken;
| 84 | 66 | pub use permission::Entity as Permission; |
| 85 | 67 | pub use personal_access_token::Entity as PersonalAccessToken; |
| 86 | 68 | pub use refresh_token::Entity as RefreshToken; |
| 87 | -#[cfg(feature = "experimental-fediverse")] | |
| 88 | -pub use remote_identity::Entity as RemoteIdentity; | |
| 89 | -#[cfg(feature = "experimental-fediverse")] | |
| 90 | -pub use fediverse_blocked_domain::Entity as FediverseBlockedDomain; | |
| 91 | -#[cfg(feature = "experimental-fediverse")] | |
| 92 | -pub use fediverse_trusted_domain::Entity as FediverseTrustedDomain; | |
| 93 | 69 | pub use resource::Entity as Resource; |
| 94 | 70 | pub use resource_share::Entity as ResourceShare; |
| 95 | 71 | pub use role::Entity as Role; |
D
crates/rustwarden-core/src/models/remote_identity.rs
+0
-61
@@ -1,61 +0,0 @@
| 1 | -//! Remote ActivityPub identity SeaORM entity (RFC 0003). | |
| 2 | -//! | |
| 3 | -//! Local cache of remote actors resolved via WebFinger + actor fetch. | |
| 4 | -//! Created and maintained by | |
| 5 | -//! [`RemoteIdentityService`](crate::services::remote_identity_service::RemoteIdentityService). | |
| 6 | -//! | |
| 7 | -//! Gated by the `experimental-fediverse` Cargo feature. | |
| 8 | - | |
| 9 | -use chrono::{DateTime, Utc}; | |
| 10 | -use sea_orm::entity::prelude::*; | |
| 11 | -use serde::{Deserialize, Serialize}; | |
| 12 | -use uuid::Uuid; | |
| 13 | - | |
| 14 | -/// `remote_identities` row. | |
| 15 | -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 16 | -#[sea_orm(table_name = "remote_identities")] | |
| 17 | -pub struct Model { | |
| 18 | - /// Stable internal identifier. | |
| 19 | - #[sea_orm(primary_key, auto_increment = false)] | |
| 20 | - pub id: Uuid, | |
| 21 | - /// Canonical ActivityPub identifier of the actor (e.g. | |
| 22 | - /// `https://other.tld/users/alice`). Unique. | |
| 23 | - #[sea_orm(unique)] | |
| 24 | - pub ap_id: String, | |
| 25 | - /// Local part of the handle (`alice` in `@alice@other.tld`). | |
| 26 | - pub username: String, | |
| 27 | - /// Remote domain (`other.tld`). | |
| 28 | - pub domain: String, | |
| 29 | - /// Optional display name advertised by the actor. | |
| 30 | - pub display_name: Option<String>, | |
| 31 | - /// Optional bio in HTML. | |
| 32 | - pub summary_html: Option<String>, | |
| 33 | - /// Inbox URL where activities targeting this actor must be POSTed. | |
| 34 | - pub inbox_url: String, | |
| 35 | - /// Optional shared inbox (instance-wide fan-out optimisation). | |
| 36 | - pub shared_inbox_url: Option<String>, | |
| 37 | - /// PEM-encoded RSA public key used for HTTP signature verification. | |
| 38 | - pub public_key_pem: String, | |
| 39 | - /// Optional avatar URL. | |
| 40 | - pub icon_url: Option<String>, | |
| 41 | - /// `Person`, `Group`, `Service`, `Application`, `Organization`. | |
| 42 | - pub actor_type: String, | |
| 43 | - /// Raw JSON document fetched from the remote actor URL (kept for | |
| 44 | - /// future re-processing if the protocol evolves). | |
| 45 | - pub raw_json: Option<serde_json::Value>, | |
| 46 | - /// Timestamp of the last successful fetch — used by the cache TTL check. | |
| 47 | - pub last_fetched_at: DateTime<Utc>, | |
| 48 | - /// Set when the remote IdP signals account deletion (`Delete{Actor}`). | |
| 49 | - pub disabled_at: Option<DateTime<Utc>>, | |
| 50 | - /// Row creation timestamp. | |
| 51 | - pub created_at: DateTime<Utc>, | |
| 52 | - /// Last row mutation timestamp. | |
| 53 | - pub updated_at: DateTime<Utc>, | |
| 54 | -} | |
| 55 | - | |
| 56 | -/// SeaORM relations for `remote_identities` (none yet — joined references | |
| 57 | -/// arrive with RFC 0008). | |
| 58 | -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 59 | -pub enum Relation {} | |
| 60 | - | |
| 61 | -impl ActiveModelBehavior for ActiveModel {} |
D
crates/rustwarden-core/src/services/actor_key_service.rs
+0
-404
@@ -1,404 +0,0 @@
| 1 | -//! ActivityPub actor-key generation and storage (RFC 0005). | |
| 2 | -//! | |
| 3 | -//! Generates RSA-2048 PKCS#8 / SPKI key pairs for local users on demand, | |
| 4 | -//! encrypts the private half via [`crate::services::crypto_service::CryptoService`], | |
| 5 | -//! and persists both halves on the `users` row. The public PEM is the | |
| 6 | -//! one Mastodon and other ActivityPub servers read from | |
| 7 | -//! `/users/{username}` to verify HTTP signatures. | |
| 8 | -//! | |
| 9 | -//! Gated by `experimental-fediverse`. | |
| 10 | - | |
| 11 | -use rsa::pkcs1::EncodeRsaPublicKey; | |
| 12 | -use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; | |
| 13 | -use rsa::rand_core::OsRng; | |
| 14 | -use rsa::{RsaPrivateKey, RsaPublicKey}; | |
| 15 | -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; | |
| 16 | -use uuid::Uuid; | |
| 17 | - | |
| 18 | -use crate::error::AppError; | |
| 19 | -use crate::models::user; | |
| 20 | -use crate::services::crypto_service::CryptoService; | |
| 21 | - | |
| 22 | -/// RSA modulus size for ActivityPub actor keys. Mastodon requires 2048. | |
| 23 | -const RSA_BITS: usize = 2048; | |
| 24 | - | |
| 25 | -/// Bundle returned by [`ActorKeyService::ensure_keys`]. | |
| 26 | -/// | |
| 27 | -/// The private PEM in this struct is **decrypted** so that signing code | |
| 28 | -/// can use it directly. It is never written to disk in this form. | |
| 29 | -#[derive(Debug, Clone)] | |
| 30 | -pub struct ActorKeyMaterial { | |
| 31 | - /// Public key as PEM (SPKI / `-----BEGIN PUBLIC KEY-----`). | |
| 32 | - pub public_pem: String, | |
| 33 | - /// Private key as PEM (PKCS#8 / `-----BEGIN PRIVATE KEY-----`). | |
| 34 | - pub private_pem: String, | |
| 35 | -} | |
| 36 | - | |
| 37 | -/// RSA actor-key generator and persistence layer for local users. | |
| 38 | -/// | |
| 39 | -/// All methods are stateless — the service owns no connection. | |
| 40 | -pub struct ActorKeyService; | |
| 41 | - | |
| 42 | -impl ActorKeyService { | |
| 43 | - /// Generate a fresh RSA-2048 key pair encoded as PEM. | |
| 44 | - /// | |
| 45 | - /// CPU-bound (~50–200 ms). Async callers should wrap this in | |
| 46 | - /// `tokio::task::spawn_blocking` to keep the runtime responsive. | |
| 47 | - pub fn generate() -> Result<ActorKeyMaterial, AppError> { | |
| 48 | - let mut rng = OsRng; | |
| 49 | - let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS) | |
| 50 | - .map_err(|e| AppError::Internal(format!("RSA key generation failed: {e}")))?; | |
| 51 | - let public_key = RsaPublicKey::from(&private_key); | |
| 52 | - | |
| 53 | - let private_pem = private_key | |
| 54 | - .to_pkcs8_pem(LineEnding::LF) | |
| 55 | - .map_err(|e| AppError::Internal(format!("PKCS#8 encode failed: {e}")))? | |
| 56 | - .to_string(); | |
| 57 | - let public_pem = public_key | |
| 58 | - .to_public_key_pem(LineEnding::LF) | |
| 59 | - .map_err(|e| AppError::Internal(format!("SPKI encode failed: {e}")))?; | |
| 60 | - | |
| 61 | - // Touch the PKCS#1 import so the dependency stays linked even if | |
| 62 | - // a refactor removes the explicit use elsewhere; cheap correctness | |
| 63 | - // check that both encoders agree on the key. | |
| 64 | - let _ = public_key | |
| 65 | - .to_pkcs1_pem(LineEnding::LF) | |
| 66 | - .map_err(|e| AppError::Internal(format!("PKCS#1 encode failed: {e}")))?; | |
| 67 | - | |
| 68 | - Ok(ActorKeyMaterial { | |
| 69 | - public_pem, | |
| 70 | - private_pem, | |
| 71 | - }) | |
| 72 | - } | |
| 73 | - | |
| 74 | - /// Read-only lookup of the persisted public PEM. Returns `Ok(None)` | |
| 75 | - /// when the user exists but has no key yet, and `AppError::NotFound` | |
| 76 | - /// when the user does not exist. | |
| 77 | - pub async fn public_key_for_user( | |
| 78 | - db: &DatabaseConnection, | |
| 79 | - user_id: Uuid, | |
| 80 | - ) -> Result<Option<String>, AppError> { | |
| 81 | - let model = user::Entity::find_by_id(user_id) | |
| 82 | - .one(db) | |
| 83 | - .await? | |
| 84 | - .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; | |
| 85 | - Ok(model.ap_public_key_pem) | |
| 86 | - } | |
| 87 | - | |
| 88 | - /// Idempotent: returns existing material if both columns are set, | |
| 89 | - /// otherwise generates a fresh pair, encrypts the private half via | |
| 90 | - /// [`CryptoService`], and persists both columns. The returned bundle | |
| 91 | - /// always carries the **decrypted** private PEM. | |
| 92 | - /// | |
| 93 | - /// Returns [`AppError::Config`] when no encryption key is configured, | |
| 94 | - /// because storing the private half in clear is forbidden. | |
| 95 | - pub async fn ensure_keys( | |
| 96 | - db: &DatabaseConnection, | |
| 97 | - user_id: Uuid, | |
| 98 | - ) -> Result<ActorKeyMaterial, AppError> { | |
| 99 | - let model = user::Entity::find_by_id(user_id) | |
| 100 | - .one(db) | |
| 101 | - .await? | |
| 102 | - .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; | |
| 103 | - | |
| 104 | - if let (Some(public_pem), Some(encrypted_private)) = | |
| 105 | - (model.ap_public_key_pem.clone(), model.ap_private_key_pem.clone()) | |
| 106 | - { | |
| 107 | - let private_pem = CryptoService::decrypt(&encrypted_private)?; | |
| 108 | - return Ok(ActorKeyMaterial { | |
| 109 | - public_pem, | |
| 110 | - private_pem, | |
| 111 | - }); | |
| 112 | - } | |
| 113 | - | |
| 114 | - if !CryptoService::is_available() { | |
| 115 | - return Err(AppError::Config( | |
| 116 | - "ENCRYPTION_KEY must be configured before generating actor keys".to_string(), | |
| 117 | - )); | |
| 118 | - } | |
| 119 | - | |
| 120 | - let material = Self::generate()?; | |
| 121 | - let encrypted_private = CryptoService::encrypt(&material.private_pem)?; | |
| 122 | - | |
| 123 | - let mut active: user::ActiveModel = model.into(); | |
| 124 | - active.ap_public_key_pem = Set(Some(material.public_pem.clone())); | |
| 125 | - active.ap_private_key_pem = Set(Some(encrypted_private)); | |
| 126 | - active.update(db).await?; | |
| 127 | - | |
| 128 | - Ok(material) | |
| 129 | - } | |
| 130 | -} | |
| 131 | - | |
| 132 | -#[cfg(test)] | |
| 133 | -// `ENV_LOCK` is intentionally held across `.await` so that two | |
| 134 | -// async tests cannot trample each other's `ENCRYPTION_KEY` env var | |
| 135 | -// while a SeaORM mock query is in flight. Tests on a `MockDatabase` | |
| 136 | -// never block; the lock is in practice released within microseconds. | |
| 137 | -#[allow(clippy::await_holding_lock)] | |
| 138 | -mod tests { | |
| 139 | - use super::*; | |
| 140 | - use chrono::Utc; | |
| 141 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 142 | - use std::sync::Mutex; | |
| 143 | - | |
| 144 | - /// Serialise tests that mutate `ENCRYPTION_KEY` so that one test setting | |
| 145 | - /// the env var does not race with another test clearing it. | |
| 146 | - static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 147 | - | |
| 148 | - fn user_row( | |
| 149 | - id: Uuid, | |
| 150 | - public: Option<String>, | |
| 151 | - private: Option<String>, | |
| 152 | - ) -> user::Model { | |
| 153 | - let now = Utc::now(); | |
| 154 | - user::Model { | |
| 155 | - id, | |
| 156 | - username: "alice".to_string(), | |
| 157 | - email: "alice@example.com".to_string(), | |
| 158 | - password_hash: "$2b$12$placeholder".to_string(), | |
| 159 | - must_change_password: false, | |
| 160 | - last_login_at: None, | |
| 161 | - last_login_ip: None, | |
| 162 | - password_changed_at: None, | |
| 163 | - email_verified: true, | |
| 164 | - email_verified_at: None, | |
| 165 | - created_at: now, | |
| 166 | - updated_at: now, | |
| 167 | - ap_public_key_pem: public, | |
| 168 | - ap_private_key_pem: private, | |
| 169 | - ap_discoverable: true, | |
| 170 | - } | |
| 171 | - } | |
| 172 | - | |
| 173 | - fn set_test_encryption_key() { | |
| 174 | - // 64 hex chars => 32 bytes => AES-256 key. | |
| 175 | - // SAFETY: tests run sequentially (--test-threads=1 in CI for this crate). | |
| 176 | - unsafe { | |
| 177 | - std::env::set_var( | |
| 178 | - "ENCRYPTION_KEY", | |
| 179 | - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 180 | - ); | |
| 181 | - } | |
| 182 | - } | |
| 183 | - | |
| 184 | - fn clear_encryption_key() { | |
| 185 | - // SAFETY: tests run sequentially. | |
| 186 | - unsafe { | |
| 187 | - std::env::remove_var("ENCRYPTION_KEY"); | |
| 188 | - std::env::remove_var("OAUTH_ENCRYPTION_KEY"); | |
| 189 | - } | |
| 190 | - } | |
| 191 | - | |
| 192 | - // ─── Pure (generation + serialization) ─────────────────────────── | |
| 193 | - | |
| 194 | - #[test] | |
| 195 | - fn generate_returns_valid_pem_pair() { | |
| 196 | - let material = ActorKeyService::generate().expect("RSA gen must succeed"); | |
| 197 | - assert!( | |
| 198 | - material.public_pem.contains("BEGIN PUBLIC KEY"), | |
| 199 | - "public PEM header missing: {}", | |
| 200 | - material.public_pem | |
| 201 | - ); | |
| 202 | - assert!( | |
| 203 | - material.private_pem.contains("BEGIN PRIVATE KEY"), | |
| 204 | - "private PEM header missing" | |
| 205 | - ); | |
| 206 | - } | |
| 207 | - | |
| 208 | - #[test] | |
| 209 | - fn generate_keys_are_2048_bits() { | |
| 210 | - use rsa::pkcs8::DecodePrivateKey; | |
| 211 | - use rsa::traits::PublicKeyParts; | |
| 212 | - let material = ActorKeyService::generate().expect("RSA gen must succeed"); | |
| 213 | - let key = RsaPrivateKey::from_pkcs8_pem(&material.private_pem) | |
| 214 | - .expect("decode generated private PEM"); | |
| 215 | - assert_eq!(key.n().bits(), RSA_BITS); | |
| 216 | - } | |
| 217 | - | |
| 218 | - #[test] | |
| 219 | - fn generate_returns_distinct_pairs() { | |
| 220 | - let a = ActorKeyService::generate().unwrap(); | |
| 221 | - let b = ActorKeyService::generate().unwrap(); | |
| 222 | - assert_ne!(a.public_pem, b.public_pem); | |
| 223 | - assert_ne!(a.private_pem, b.private_pem); | |
| 224 | - } | |
| 225 | - | |
| 226 | - #[test] | |
| 227 | - fn actor_key_material_clone_works() { | |
| 228 | - let material = ActorKeyMaterial { | |
| 229 | - public_pem: "pub".into(), | |
| 230 | - private_pem: "priv".into(), | |
| 231 | - }; | |
| 232 | - let cloned = material.clone(); | |
| 233 | - assert_eq!(cloned.public_pem, "pub"); | |
| 234 | - assert_eq!(cloned.private_pem, "priv"); | |
| 235 | - } | |
| 236 | - | |
| 237 | - // ─── DB-mocked (sea-orm/mock) ──────────────────────────────────── | |
| 238 | - | |
| 239 | - #[tokio::test] | |
| 240 | - async fn public_key_for_user_returns_none_when_unset() { | |
| 241 | - let user_id = Uuid::new_v4(); | |
| 242 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 243 | - .append_query_results([vec![user_row(user_id, None, None)]]) | |
| 244 | - .into_connection(); | |
| 245 | - | |
| 246 | - let pem = ActorKeyService::public_key_for_user(&db, user_id) | |
| 247 | - .await | |
| 248 | - .expect("query ok"); | |
| 249 | - assert!(pem.is_none()); | |
| 250 | - } | |
| 251 | - | |
| 252 | - #[tokio::test] | |
| 253 | - async fn public_key_for_user_returns_some_when_set() { | |
| 254 | - let user_id = Uuid::new_v4(); | |
| 255 | - let pem = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----\n".to_string(); | |
| 256 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 257 | - .append_query_results([vec![user_row(user_id, Some(pem.clone()), None)]]) | |
| 258 | - .into_connection(); | |
| 259 | - | |
| 260 | - let result = ActorKeyService::public_key_for_user(&db, user_id) | |
| 261 | - .await | |
| 262 | - .expect("query ok"); | |
| 263 | - assert_eq!(result.as_deref(), Some(pem.as_str())); | |
| 264 | - } | |
| 265 | - | |
| 266 | - #[tokio::test] | |
| 267 | - async fn public_key_for_user_returns_error_when_user_missing() { | |
| 268 | - let user_id = Uuid::new_v4(); | |
| 269 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 270 | - .append_query_results([Vec::<user::Model>::new()]) | |
| 271 | - .into_connection(); | |
| 272 | - | |
| 273 | - let err = ActorKeyService::public_key_for_user(&db, user_id) | |
| 274 | - .await | |
| 275 | - .expect_err("must surface NotFound"); | |
| 276 | - assert!(matches!(err, AppError::NotFound(_))); | |
| 277 | - } | |
| 278 | - | |
| 279 | - #[tokio::test] | |
| 280 | - async fn ensure_keys_returns_existing_when_present() { | |
| 281 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 282 | - set_test_encryption_key(); | |
| 283 | - let user_id = Uuid::new_v4(); | |
| 284 | - let public_pem = "-----BEGIN PUBLIC KEY-----\nstored\n-----END PUBLIC KEY-----\n"; | |
| 285 | - let plaintext_priv = "-----BEGIN PRIVATE KEY-----\nstored-priv\n-----END PRIVATE KEY-----\n"; | |
| 286 | - let encrypted_priv = CryptoService::encrypt(plaintext_priv).expect("encrypt"); | |
| 287 | - | |
| 288 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 289 | - .append_query_results([vec![user_row( | |
| 290 | - user_id, | |
| 291 | - Some(public_pem.to_string()), | |
| 292 | - Some(encrypted_priv), | |
| 293 | - )]]) | |
| 294 | - .into_connection(); | |
| 295 | - | |
| 296 | - let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 297 | - .await | |
| 298 | - .expect("ensure_keys ok"); | |
| 299 | - assert_eq!(material.public_pem, public_pem); | |
| 300 | - assert_eq!(material.private_pem, plaintext_priv); | |
| 301 | - } | |
| 302 | - | |
| 303 | - #[tokio::test] | |
| 304 | - async fn ensure_keys_generates_when_missing() { | |
| 305 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 306 | - set_test_encryption_key(); | |
| 307 | - let user_id = Uuid::new_v4(); | |
| 308 | - let initial = user_row(user_id, None, None); | |
| 309 | - | |
| 310 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 311 | - // 1st: SELECT in ensure_keys | |
| 312 | - .append_query_results([vec![initial.clone()]]) | |
| 313 | - // 2nd: SELECT issued by .update() to load fresh state | |
| 314 | - .append_query_results([vec![initial]]) | |
| 315 | - .append_exec_results([MockExecResult { | |
| 316 | - last_insert_id: 0, | |
| 317 | - rows_affected: 1, | |
| 318 | - }]) | |
| 319 | - .into_connection(); | |
| 320 | - | |
| 321 | - let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 322 | - .await | |
| 323 | - .expect("ensure_keys ok"); | |
| 324 | - assert!(material.public_pem.contains("BEGIN PUBLIC KEY")); | |
| 325 | - assert!(material.private_pem.contains("BEGIN PRIVATE KEY")); | |
| 326 | - } | |
| 327 | - | |
| 328 | - #[tokio::test] | |
| 329 | - async fn ensure_keys_persists_encrypted_private_key() { | |
| 330 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 331 | - set_test_encryption_key(); | |
| 332 | - let user_id = Uuid::new_v4(); | |
| 333 | - let initial = user_row(user_id, None, None); | |
| 334 | - | |
| 335 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 336 | - .append_query_results([vec![initial.clone()]]) | |
| 337 | - .append_query_results([vec![initial]]) | |
| 338 | - .append_exec_results([MockExecResult { | |
| 339 | - last_insert_id: 0, | |
| 340 | - rows_affected: 1, | |
| 341 | - }]) | |
| 342 | - .into_connection(); | |
| 343 | - | |
| 344 | - let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 345 | - .await | |
| 346 | - .unwrap(); | |
| 347 | - // Sanity: round-trip via CryptoService confirms the private PEM | |
| 348 | - // returned to the caller is decrypted, while the value the | |
| 349 | - // service handed to the DB layer was `enc:`-prefixed. | |
| 350 | - let re_encrypted = CryptoService::encrypt(&material.private_pem).unwrap(); | |
| 351 | - assert!(re_encrypted.starts_with("enc:")); | |
| 352 | - } | |
| 353 | - | |
| 354 | - #[tokio::test] | |
| 355 | - async fn ensure_keys_returns_decrypted_private_pem() { | |
| 356 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 357 | - set_test_encryption_key(); | |
| 358 | - let user_id = Uuid::new_v4(); | |
| 359 | - let initial = user_row(user_id, None, None); | |
| 360 | - | |
| 361 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 362 | - .append_query_results([vec![initial.clone()]]) | |
| 363 | - .append_query_results([vec![initial]]) | |
| 364 | - .append_exec_results([MockExecResult { | |
| 365 | - last_insert_id: 0, | |
| 366 | - rows_affected: 1, | |
| 367 | - }]) | |
| 368 | - .into_connection(); | |
| 369 | - | |
| 370 | - let material = ActorKeyService::ensure_keys(&db, user_id).await.unwrap(); | |
| 371 | - assert!(material.private_pem.starts_with("-----BEGIN PRIVATE KEY-----")); | |
| 372 | - assert!(material.private_pem.contains("-----END PRIVATE KEY-----")); | |
| 373 | - } | |
| 374 | - | |
| 375 | - #[tokio::test] | |
| 376 | - async fn ensure_keys_errors_when_encryption_key_missing() { | |
| 377 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 378 | - clear_encryption_key(); | |
| 379 | - let user_id = Uuid::new_v4(); | |
| 380 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 381 | - .append_query_results([vec![user_row(user_id, None, None)]]) | |
| 382 | - .into_connection(); | |
| 383 | - | |
| 384 | - let err = ActorKeyService::ensure_keys(&db, user_id) | |
| 385 | - .await | |
| 386 | - .expect_err("must error without encryption key"); | |
| 387 | - assert!(matches!(err, AppError::Config(_))); | |
| 388 | - } | |
| 389 | - | |
| 390 | - #[tokio::test] | |
| 391 | - async fn ensure_keys_returns_error_when_user_missing() { | |
| 392 | - let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 393 | - set_test_encryption_key(); | |
| 394 | - let user_id = Uuid::new_v4(); | |
| 395 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 396 | - .append_query_results([Vec::<user::Model>::new()]) | |
| 397 | - .into_connection(); | |
| 398 | - | |
| 399 | - let err = ActorKeyService::ensure_keys(&db, user_id) | |
| 400 | - .await | |
| 401 | - .expect_err("must surface NotFound"); | |
| 402 | - assert!(matches!(err, AppError::NotFound(_))); | |
| 403 | - } | |
| 404 | -} |
D
crates/rustwarden-core/src/services/fediverse_auth_service.rs
+0
-958
@@ -1,958 +0,0 @@
| 1 | -//! AP-native authentication flow service (RFC 0006). | |
| 2 | -//! | |
| 3 | -//! Orchestrates the four-step challenge state machine on the | |
| 4 | -//! client-side (`b.tld`): | |
| 5 | -//! | |
| 6 | -//! 1. `initiate` — persist a fresh `pending` challenge with a 256-bit | |
| 7 | -//! cryptographic nonce. | |
| 8 | -//! 2. `record_decision` — IdP-side approve/reject promotes the row. | |
| 9 | -//! 3. `status` — caller polls; expired rows transition to `Expired`. | |
| 10 | -//! 4. `consume` — mint a remote JWT from an `Approved` challenge, | |
| 11 | -//! flipping the row to `Consumed` (one-shot). | |
| 12 | -//! | |
| 13 | -//! Gated by `experimental-fediverse`. | |
| 14 | - | |
| 15 | -use base64::Engine; | |
| 16 | -use chrono::{DateTime, Duration as ChronoDuration, Utc}; | |
| 17 | -use rand::RngCore; | |
| 18 | -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; | |
| 19 | -use std::time::Duration; | |
| 20 | -use uuid::Uuid; | |
| 21 | - | |
| 22 | -use crate::auth::jwt::JwtService; | |
| 23 | -use crate::config::auth::AuthConfig; | |
| 24 | -use crate::config::fediverse::FediverseConfig; | |
| 25 | -use crate::error::AppError; | |
| 26 | -use crate::models::fediverse_auth_challenge; | |
| 27 | - | |
| 28 | -/// State machine for [`fediverse_auth_challenge::Model::status`]. | |
| 29 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 30 | -pub enum ChallengeStatus { | |
| 31 | - /// Awaiting IdP decision. | |
| 32 | - Pending, | |
| 33 | - /// IdP user clicked Approve. | |
| 34 | - Approved, | |
| 35 | - /// IdP user clicked Reject. | |
| 36 | - Rejected, | |
| 37 | - /// `expires_at` reached without decision. | |
| 38 | - Expired, | |
| 39 | - /// Already exchanged for a JWT (one-shot guard). | |
| 40 | - Consumed, | |
| 41 | -} | |
| 42 | - | |
| 43 | -impl ChallengeStatus { | |
| 44 | - /// Wire form stored in the `status` column. | |
| 45 | - pub fn as_str(self) -> &'static str { | |
| 46 | - match self { | |
| 47 | - Self::Pending => "pending", | |
| 48 | - Self::Approved => "approved", | |
| 49 | - Self::Rejected => "rejected", | |
| 50 | - Self::Expired => "expired", | |
| 51 | - Self::Consumed => "consumed", | |
| 52 | - } | |
| 53 | - } | |
| 54 | - | |
| 55 | - /// Best-effort parse — unknown values fall back to `Pending` so a | |
| 56 | - /// data corruption never silently mints a JWT. | |
| 57 | - pub fn parse(raw: &str) -> Self { | |
| 58 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 59 | - "approved" => Self::Approved, | |
| 60 | - "rejected" => Self::Rejected, | |
| 61 | - "expired" => Self::Expired, | |
| 62 | - "consumed" => Self::Consumed, | |
| 63 | - _ => Self::Pending, | |
| 64 | - } | |
| 65 | - } | |
| 66 | -} | |
| 67 | - | |
| 68 | -/// Caller input for [`FediverseAuthService::initiate`]. | |
| 69 | -#[derive(Debug, Clone)] | |
| 70 | -pub struct InitiateRequest { | |
| 71 | - /// `@user@domain` handle the user typed. | |
| 72 | - pub handle: String, | |
| 73 | - /// Optional URL the caller wants the browser redirected to after | |
| 74 | - /// the JWT is minted. | |
| 75 | - pub redirect_url: Option<String>, | |
| 76 | -} | |
| 77 | - | |
| 78 | -/// Receipt returned by [`FediverseAuthService::initiate`]. | |
| 79 | -#[derive(Debug, Clone)] | |
| 80 | -pub struct InitiatedChallenge { | |
| 81 | - /// PK of the persisted row. | |
| 82 | - pub id: Uuid, | |
| 83 | - /// Cryptographic nonce embedded in the AP `AuthRequest` body. | |
| 84 | - pub nonce: String, | |
| 85 | - /// Hard deadline. | |
| 86 | - pub expires_at: DateTime<Utc>, | |
| 87 | -} | |
| 88 | - | |
| 89 | -/// IdP-side input for [`FediverseAuthService::record_decision`]. | |
| 90 | -#[derive(Debug, Clone)] | |
| 91 | -pub enum ChallengeDecision { | |
| 92 | - /// User approved; bind the resolved `remote_identities.id` to the | |
| 93 | - /// challenge so that `consume` can mint the JWT. | |
| 94 | - Approve { | |
| 95 | - /// FK of the resolved remote identity bound to this approve. | |
| 96 | - remote_identity_id: Uuid, | |
| 97 | - }, | |
| 98 | - /// User rejected. | |
| 99 | - Reject, | |
| 100 | -} | |
| 101 | - | |
| 102 | -/// Output of [`FediverseAuthService::consume`]. | |
| 103 | -#[derive(Debug, Clone)] | |
| 104 | -pub struct ConsumedSession { | |
| 105 | - /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`). | |
| 106 | - pub jwt: String, | |
| 107 | - /// FK of the federated identity the JWT speaks for. | |
| 108 | - pub remote_identity_id: Uuid, | |
| 109 | - /// `@user@domain` handle for log/audit cohesion. | |
| 110 | - pub handle: String, | |
| 111 | -} | |
| 112 | - | |
| 113 | -/// Stateless service exposing the AP-native auth state machine. | |
| 114 | -pub struct FediverseAuthService; | |
| 115 | - | |
| 116 | -impl FediverseAuthService { | |
| 117 | - /// Step 1 — persist a `pending` challenge for the given handle. | |
| 118 | - /// | |
| 119 | - /// The caller is responsible for separately enqueueing the AP | |
| 120 | - /// `AuthRequest` activity to the remote inbox via | |
| 121 | - /// `FediverseOutboundService::enqueue` and the helper | |
| 122 | - /// [`build_auth_request_payload`] below. | |
| 123 | - pub async fn initiate( | |
| 124 | - db: &DatabaseConnection, | |
| 125 | - request: InitiateRequest, | |
| 126 | - ttl: Duration, | |
| 127 | - ) -> Result<InitiatedChallenge, AppError> { | |
| 128 | - let domain = handle_domain(&request.handle)?; | |
| 129 | - let now = Utc::now(); | |
| 130 | - let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { | |
| 131 | - AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) | |
| 132 | - })?; | |
| 133 | - let nonce = generate_nonce(); | |
| 134 | - | |
| 135 | - let id = Uuid::new_v4(); | |
| 136 | - let active = fediverse_auth_challenge::ActiveModel { | |
| 137 | - id: Set(id), | |
| 138 | - nonce: Set(nonce.clone()), | |
| 139 | - handle: Set(request.handle.clone()), | |
| 140 | - domain: Set(domain), | |
| 141 | - requested_remote_id: Set(None), | |
| 142 | - status: Set(ChallengeStatus::Pending.as_str().to_string()), | |
| 143 | - redirect_url: Set(request.redirect_url), | |
| 144 | - created_at: Set(now), | |
| 145 | - expires_at: Set(expires_at), | |
| 146 | - consumed_at: Set(None), | |
| 147 | - }; | |
| 148 | - let inserted = active.insert(db).await?; | |
| 149 | - | |
| 150 | - Ok(InitiatedChallenge { | |
| 151 | - id: inserted.id, | |
| 152 | - nonce: inserted.nonce, | |
| 153 | - expires_at: inserted.expires_at, | |
| 154 | - }) | |
| 155 | - } | |
| 156 | - | |
| 157 | - /// Step 2 — IdP records the user's decision. | |
| 158 | - pub async fn record_decision( | |
| 159 | - db: &DatabaseConnection, | |
| 160 | - challenge_id: Uuid, | |
| 161 | - decision: ChallengeDecision, | |
| 162 | - ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 163 | - let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 164 | - .one(db) | |
| 165 | - .await? | |
| 166 | - .ok_or_else(|| { | |
| 167 | - AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 168 | - })?; | |
| 169 | - | |
| 170 | - if model.status != ChallengeStatus::Pending.as_str() { | |
| 171 | - return Err(AppError::Validation(format!( | |
| 172 | - "Cannot record decision: challenge is in state {}", | |
| 173 | - model.status | |
| 174 | - ))); | |
| 175 | - } | |
| 176 | - | |
| 177 | - let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 178 | - match decision { | |
| 179 | - ChallengeDecision::Approve { remote_identity_id } => { | |
| 180 | - active.status = Set(ChallengeStatus::Approved.as_str().to_string()); | |
| 181 | - active.requested_remote_id = Set(Some(remote_identity_id)); | |
| 182 | - } | |
| 183 | - ChallengeDecision::Reject => { | |
| 184 | - active.status = Set(ChallengeStatus::Rejected.as_str().to_string()); | |
| 185 | - } | |
| 186 | - } | |
| 187 | - Ok(active.update(db).await?) | |
| 188 | - } | |
| 189 | - | |
| 190 | - /// Step 1bis (RFC 0006 Phase 2 — inbox enrichment) : called by the | |
| 191 | - /// IdP-side `/fediverse/auth/inbox` handler **after** the signature | |
| 192 | - /// has been verified. Parses the AP `AuthRequest` body, resolves the | |
| 193 | - /// requester's `remote_identities.id` from the `actor` URL, and | |
| 194 | - /// inserts a `Pending` challenge bound to that requester. | |
| 195 | - /// | |
| 196 | - /// Without this step, the consent screen (RFC 0023 Slice B iter. 4) | |
| 197 | - /// cannot Approve : `record_decision` requires `requested_remote_id` | |
| 198 | - /// which used to stay `None` because the inbox handler only | |
| 199 | - /// acknowledged the activity. | |
| 200 | - /// | |
| 201 | - /// Returns the inserted row. The IdP UI can then show | |
| 202 | - /// `/fediverse/consent/{returned.id}` to the local user. | |
| 203 | - /// | |
| 204 | - /// **Validation** : | |
| 205 | - /// - `body.target` must equal `@<local_user>@<idp_domain>` — | |
| 206 | - /// otherwise `AppError::Validation`. | |
| 207 | - /// - `body.actor` must point to an existing `remote_identities` row — | |
| 208 | - /// the row is created upstream by the signature verifier | |
| 209 | - /// ([`crate::fediverse::http_signatures::HttpSigVerifier::with_resolver`]) | |
| 210 | - /// when the actor doc is fetched the first time. | |
| 211 | - pub async fn persist_inbound_request( | |
| 212 | - db: &DatabaseConnection, | |
| 213 | - body: &serde_json::Value, | |
| 214 | - idp_domain: &str, | |
| 215 | - ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 216 | - use crate::models::remote_identity; | |
| 217 | - use sea_orm::QueryFilter; | |
| 218 | - | |
| 219 | - let target = body | |
| 220 | - .get("target") | |
| 221 | - .and_then(|v| v.as_str()) | |
| 222 | - .ok_or_else(|| AppError::Validation("AuthRequest body missing `target`".into()))? | |
| 223 | - .to_string(); | |
| 224 | - let actor_url = body | |
| 225 | - .get("actor") | |
| 226 | - .and_then(|v| v.as_str()) | |
| 227 | - .ok_or_else(|| AppError::Validation("AuthRequest body missing `actor`".into()))? | |
| 228 | - .to_string(); | |
| 229 | - let nonce = body | |
| 230 | - .get("nonce") | |
| 231 | - .and_then(|v| v.as_str()) | |
| 232 | - .ok_or_else(|| AppError::Validation("AuthRequest body missing `nonce`".into()))? | |
| 233 | - .to_string(); | |
| 234 | - | |
| 235 | - // Validate target domain matches the IdP — otherwise the request | |
| 236 | - // is misrouted and we refuse rather than create a stray challenge. | |
| 237 | - let target_domain = handle_domain(&target)?; | |
| 238 | - if target_domain != idp_domain.to_ascii_lowercase() { | |
| 239 | - return Err(AppError::Validation(format!( | |
| 240 | - "AuthRequest target domain `{target_domain}` does not match this IdP `{idp_domain}`", | |
| 241 | - ))); | |
| 242 | - } | |
| 243 | - | |
| 244 | - // Resolve the requester's remote_identities row by AP id. The | |
| 245 | - // signature verifier ran upstream and already required this row, | |
| 246 | - // so a NotFound here means a code-ordering bug and we surface it. | |
| 247 | - use sea_orm::ColumnTrait; | |
| 248 | - let requester = remote_identity::Entity::find() | |
| 249 | - .filter(remote_identity::Column::ApId.eq(&actor_url)) | |
| 250 | - .one(db) | |
| 251 | - .await? | |
| 252 | - .ok_or_else(|| { | |
| 253 | - AppError::Validation(format!( | |
| 254 | - "Requester actor `{actor_url}` not found in remote_identities cache; \ | |
| 255 | - signature verifier should have populated this row", | |
| 256 | - )) | |
| 257 | - })?; | |
| 258 | - | |
| 259 | - // Parse `expiresAt` (RFC3339); fallback to default TTL if absent | |
| 260 | - // or malformed (5 min, RFC 0006 §6). | |
| 261 | - let expires_at = body | |
| 262 | - .get("expiresAt") | |
| 263 | - .and_then(|v| v.as_str()) | |
| 264 | - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) | |
| 265 | - .map(|dt| dt.with_timezone(&Utc)) | |
| 266 | - .unwrap_or_else(|| Utc::now() + ChronoDuration::minutes(5)); | |
| 267 | - | |
| 268 | - let id = Uuid::new_v4(); | |
| 269 | - let active = fediverse_auth_challenge::ActiveModel { | |
| 270 | - id: Set(id), | |
| 271 | - nonce: Set(nonce), | |
| 272 | - handle: Set(target), | |
| 273 | - // Domain stored is the requester's domain (b.tld) — same | |
| 274 | - // semantic as the client-side `initiate` row stores | |
| 275 | - // `b.tld` for the IdP it's calling. Symmetrical. | |
| 276 | - domain: Set(requester.domain.clone()), | |
| 277 | - requested_remote_id: Set(Some(requester.id)), | |
| 278 | - status: Set(ChallengeStatus::Pending.as_str().to_string()), | |
| 279 | - redirect_url: Set(None), | |
| 280 | - created_at: Set(Utc::now()), | |
| 281 | - expires_at: Set(expires_at), | |
| 282 | - consumed_at: Set(None), | |
| 283 | - }; | |
| 284 | - Ok(active.insert(db).await?) | |
| 285 | - } | |
| 286 | - | |
| 287 | - /// Step 1ter (RFC 0025 — back-channel) : called by the SP-side | |
| 288 | - /// `/fediverse/auth/inbox` when the IdP delivers an | |
| 289 | - /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's | |
| 290 | - /// `object` IRI references B's original AuthRequest; the nonce is | |
| 291 | - /// the last URL segment. | |
| 292 | - /// | |
| 293 | - /// Security: the approver's domain (`actor`'s host) must match the | |
| 294 | - /// `domain` recorded on the local challenge when it was initiated. | |
| 295 | - /// Without that check, `evil.tld` could fabricate an `Accept` for a | |
| 296 | - /// nonce it observed and trick B into approving it. | |
| 297 | - /// | |
| 298 | - /// Idempotent: replaying the same decision on a challenge that's | |
| 299 | - /// already left `Pending` returns `Ok(())` without DB write. | |
| 300 | - pub async fn record_inbound_decision( | |
| 301 | - db: &DatabaseConnection, | |
| 302 | - body: &serde_json::Value, | |
| 303 | - ) -> Result<(), AppError> { | |
| 304 | - let activity_type = body | |
| 305 | - .get("type") | |
| 306 | - .and_then(|v| v.as_str()) | |
| 307 | - .ok_or_else(|| AppError::Validation("activity missing `type`".into()))? | |
| 308 | - .to_string(); | |
| 309 | - | |
| 310 | - let approver_actor_url = body | |
| 311 | - .get("actor") | |
| 312 | - .and_then(|v| v.as_str()) | |
| 313 | - .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? | |
| 314 | - .to_string(); | |
| 315 | - | |
| 316 | - let nonce = extract_nonce_from_decision_object(body)?; | |
| 317 | - | |
| 318 | - use crate::models::remote_identity; | |
| 319 | - use sea_orm::{ColumnTrait, QueryFilter}; | |
| 320 | - | |
| 321 | - let challenge = fediverse_auth_challenge::Entity::find() | |
| 322 | - .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) | |
| 323 | - .one(db) | |
| 324 | - .await? | |
| 325 | - .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; | |
| 326 | - | |
| 327 | - // Idempotence on terminal states. | |
| 328 | - if challenge.status != ChallengeStatus::Pending.as_str() { | |
| 329 | - return Ok(()); | |
| 330 | - } | |
| 331 | - if challenge.expires_at < Utc::now() { | |
| 332 | - return Err(AppError::Validation(format!( | |
| 333 | - "challenge {} expired before decision arrived", | |
| 334 | - challenge.id | |
| 335 | - ))); | |
| 336 | - } | |
| 337 | - | |
| 338 | - // Security: the approver must live on the IdP that this | |
| 339 | - // challenge originally targeted. | |
| 340 | - let approver_domain = url::Url::parse(&approver_actor_url) | |
| 341 | - .ok() | |
| 342 | - .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 343 | - .ok_or_else(|| { | |
| 344 | - AppError::Validation(format!( | |
| 345 | - "approver actor URL has no host: {approver_actor_url}" | |
| 346 | - )) | |
| 347 | - })?; | |
| 348 | - if approver_domain != challenge.domain.to_ascii_lowercase() { | |
| 349 | - return Err(AppError::Validation(format!( | |
| 350 | - "approver domain `{approver_domain}` does not match challenge IdP `{}`", | |
| 351 | - challenge.domain | |
| 352 | - ))); | |
| 353 | - } | |
| 354 | - | |
| 355 | - // Resolve the approver in `remote_identities`. F2's verifier | |
| 356 | - // populated this row on signature verification; if it's still | |
| 357 | - // absent, surface the integrity issue rather than approve blindly. | |
| 358 | - let approver = remote_identity::Entity::find() | |
| 359 | - .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) | |
| 360 | - .one(db) | |
| 361 | - .await? | |
| 362 | - .ok_or_else(|| { | |
| 363 | - AppError::Validation(format!( | |
| 364 | - "approver {approver_actor_url} not in remote_identities cache; \ | |
| 365 | - signature verifier should have populated this row", | |
| 366 | - )) | |
| 367 | - })?; | |
| 368 | - | |
| 369 | - let new_status = match activity_type.as_str() { | |
| 370 | - "Accept" => ChallengeStatus::Approved, | |
| 371 | - "Reject" => ChallengeStatus::Rejected, | |
| 372 | - other => { | |
| 373 | - return Err(AppError::Validation(format!( | |
| 374 | - "record_inbound_decision: unsupported type `{other}` (expected Accept|Reject)" | |
| 375 | - ))) | |
| 376 | - } | |
| 377 | - }; | |
| 378 | - | |
| 379 | - let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); | |
| 380 | - active.status = Set(new_status.as_str().to_string()); | |
| 381 | - active.requested_remote_id = Set(Some(approver.id)); | |
| 382 | - active.update(db).await?; | |
| 383 | - Ok(()) | |
| 384 | - } | |
| 385 | - | |
| 386 | - /// Step 3 — caller polls. Promotes pending-but-expired rows to | |
| 387 | - /// `Expired` lazily so the caller observes a stable terminal state. | |
| 388 | - pub async fn status( | |
| 389 | - db: &DatabaseConnection, | |
| 390 | - challenge_id: Uuid, | |
| 391 | - ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 392 | - let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 393 | - .one(db) | |
| 394 | - .await? | |
| 395 | - .ok_or_else(|| { | |
| 396 | - AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 397 | - })?; | |
| 398 | - | |
| 399 | - if model.status == ChallengeStatus::Pending.as_str() | |
| 400 | - && model.expires_at < Utc::now() | |
| 401 | - { | |
| 402 | - let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 403 | - active.status = Set(ChallengeStatus::Expired.as_str().to_string()); | |
| 404 | - return Ok(active.update(db).await?); | |
| 405 | - } | |
| 406 | - Ok(model) | |
| 407 | - } | |
| 408 | - | |
| 409 | - /// Step 4 — atomically mint a remote JWT from an `Approved` | |
| 410 | - /// challenge. | |
| 411 | - pub async fn consume( | |
| 412 | - db: &DatabaseConnection, | |
| 413 | - config: &AuthConfig, | |
| 414 | - challenge_id: Uuid, | |
| 415 | - ) -> Result<ConsumedSession, AppError> { | |
| 416 | - let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 417 | - .one(db) | |
| 418 | - .await? | |
| 419 | - .ok_or_else(|| { | |
| 420 | - AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 421 | - })?; | |
| 422 | - | |
| 423 | - match ChallengeStatus::parse(&model.status) { | |
| 424 | - ChallengeStatus::Approved => {} | |
| 425 | - ChallengeStatus::Consumed => { | |
| 426 | - return Err(AppError::Validation( | |
| 427 | - "Challenge already consumed".to_string(), | |
| 428 | - )); | |
| 429 | - } | |
| 430 | - other => { | |
| 431 | - return Err(AppError::Validation(format!( | |
| 432 | - "Cannot consume challenge in state {}", | |
| 433 | - other.as_str() | |
| 434 | - ))); | |
| 435 | - } | |
| 436 | - } | |
| 437 | - | |
| 438 | - let remote_identity_id = model.requested_remote_id.ok_or_else(|| { | |
| 439 | - AppError::Internal( | |
| 440 | - "Approved challenge missing remote_identity_id (data integrity bug)" | |
| 441 | - .to_string(), | |
| 442 | - ) | |
| 443 | - })?; | |
| 444 | - let handle = model.handle.clone(); | |
| 445 | - | |
| 446 | - let jwt = JwtService::generate_remote_token(config, remote_identity_id, &handle)?; | |
| 447 | - | |
| 448 | - let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 449 | - active.status = Set(ChallengeStatus::Consumed.as_str().to_string()); | |
| 450 | - active.consumed_at = Set(Some(Utc::now())); | |
| 451 | - active.update(db).await?; | |
| 452 | - | |
| 453 | - Ok(ConsumedSession { | |
| 454 | - jwt, | |
| 455 | - remote_identity_id, | |
| 456 | - handle, | |
| 457 | - }) | |
| 458 | - } | |
| 459 | -} | |
| 460 | - | |
| 461 | -/// Build the JSON-LD body of the `AuthRequest` activity (unsigned). | |
| 462 | -/// | |
| 463 | -/// The HTTP signature is attached at delivery time by the | |
| 464 | -/// `OutboundDelivery` implementation supplied to the worker. | |
| 465 | -pub fn build_auth_request_payload( | |
| 466 | - config: &FediverseConfig, | |
| 467 | - challenge: &InitiatedChallenge, | |
| 468 | - target_handle: &str, | |
| 469 | -) -> serde_json::Value { | |
| 470 | - serde_json::json!({ | |
| 471 | - "@context": [ | |
| 472 | - "https://www.w3.org/ns/activitystreams", | |
| 473 | - "https://schema.rustwarden.org/auth-request/v1" | |
| 474 | - ], | |
| 475 | - "type": "AuthRequest", | |
| 476 | - "id": format!("{}auth-requests/{}", config.base_url(), challenge.id), | |
| 477 | - "actor": format!("{}", config.base_url()), | |
| 478 | - "target": target_handle, | |
| 479 | - "nonce": challenge.nonce, | |
| 480 | - "expiresAt": challenge.expires_at.to_rfc3339(), | |
| 481 | - }) | |
| 482 | -} | |
| 483 | - | |
| 484 | -/// Build the JSON-LD body of an `Accept(AuthRequest)` or | |
| 485 | -/// `Reject(AuthRequest)` activity (RFC 0025). | |
| 486 | -/// | |
| 487 | -/// The IRI-only form of `object` would require the IdP to know B's | |
| 488 | -/// AuthRequest URL — which we don't store. We use the inline-object | |
| 489 | -/// form `{ type: "AuthRequest", nonce }` instead; the receiving side | |
| 490 | -/// extracts the nonce via [`extract_nonce_from_decision_object`]. | |
| 491 | -/// | |
| 492 | -/// `idp_base_url` is the canonical base URL of A (the IdP, e.g. | |
| 493 | -/// `http://alpha.local/`). `approver_actor_url` is the AP id of the | |
| 494 | -/// local user that clicked Approve / Reject. `requester_inbox_url` is | |
| 495 | -/// the inbox of B's user that initiated the AuthRequest (delivery | |
| 496 | -/// target). `approve=true` emits Accept, `false` emits Reject. | |
| 497 | -pub fn build_auth_decision_payload( | |
| 498 | - idp_base_url: &url::Url, | |
| 499 | - approver_actor_url: &str, | |
| 500 | - requester_inbox_url: &str, | |
| 501 | - nonce: &str, | |
| 502 | - approve: bool, | |
| 503 | -) -> serde_json::Value { | |
| 504 | - let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 505 | - let activity_id = format!("{}auth-decisions/{}", idp_base_url, Uuid::new_v4()); | |
| 506 | - serde_json::json!({ | |
| 507 | - "@context": [ | |
| 508 | - "https://www.w3.org/ns/activitystreams", | |
| 509 | - { | |
| 510 | - "AuthRequest": { | |
| 511 | - "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" | |
| 512 | - } | |
| 513 | - } | |
| 514 | - ], | |
| 515 | - "type": activity_type, | |
| 516 | - "id": activity_id, | |
| 517 | - "actor": approver_actor_url, | |
| 518 | - "object": { | |
| 519 | - "type": "AuthRequest", | |
| 520 | - "nonce": nonce, | |
| 521 | - }, | |
| 522 | - "to": [ requester_inbox_url ], | |
| 523 | - }) | |
| 524 | -} | |
| 525 | - | |
| 526 | -/// Extract the AuthRequest nonce from the `object` field of an inbound | |
| 527 | -/// `Accept` or `Reject` activity. Supports two payload shapes: | |
| 528 | -/// - **IRI form** (canonical) : `"object": "http://b.tld/auth-requests/<nonce>"` | |
| 529 | -/// — the nonce is the last URL path segment. | |
| 530 | -/// - **Inline-object form** (defensive fallback) : `"object": { "nonce": "<v>" }`. | |
| 531 | -fn extract_nonce_from_decision_object(body: &serde_json::Value) -> Result<String, AppError> { | |
| 532 | - let object = body | |
| 533 | - .get("object") | |
| 534 | - .ok_or_else(|| AppError::Validation("Accept/Reject missing `object`".into()))?; | |
| 535 | - | |
| 536 | - if let Some(iri) = object.as_str() { | |
| 537 | - let nonce = iri.rsplit('/').next().unwrap_or("").trim(); | |
| 538 | - if nonce.is_empty() { | |
| 539 | - return Err(AppError::Validation(format!( | |
| 540 | - "cannot extract nonce from object IRI `{iri}`" | |
| 541 | - ))); | |
| 542 | - } | |
| 543 | - return Ok(nonce.to_string()); | |
| 544 | - } | |
| 545 | - if let Some(inline) = object.as_object() | |
| 546 | - && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) | |
| 547 | - && !nonce.is_empty() | |
| 548 | - { | |
| 549 | - return Ok(nonce.to_string()); | |
| 550 | - } | |
| 551 | - Err(AppError::Validation( | |
| 552 | - "cannot extract nonce from Accept/Reject `object`".into(), | |
| 553 | - )) | |
| 554 | -} | |
| 555 | - | |
| 556 | -fn handle_domain(handle: &str) -> Result<String, AppError> { | |
| 557 | - let trimmed = handle.trim().trim_start_matches('@'); | |
| 558 | - let (_, domain) = trimmed.split_once('@').ok_or_else(|| { | |
| 559 | - AppError::Validation(format!( | |
| 560 | - "Invalid handle '{handle}': expected user@domain", | |
| 561 | - )) | |
| 562 | - })?; | |
| 563 | - if domain.is_empty() { | |
| 564 | - return Err(AppError::Validation(format!( | |
| 565 | - "Invalid handle '{handle}': empty domain", | |
| 566 | - ))); | |
| 567 | - } | |
| 568 | - Ok(domain.to_ascii_lowercase()) | |
| 569 | -} | |
| 570 | - | |
| 571 | -fn generate_nonce() -> String { | |
| 572 | - let mut buf = [0u8; 32]; | |
| 573 | - rand::rng().fill_bytes(&mut buf); | |
| 574 | - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) | |
| 575 | -} | |
| 576 | - | |
| 577 | -#[cfg(test)] | |
| 578 | -mod tests { | |
| 579 | - use super::*; | |
| 580 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 581 | - | |
| 582 | - fn fresh_pending(id: Uuid, handle: &str) -> fediverse_auth_challenge::Model { | |
| 583 | - let now = Utc::now(); | |
| 584 | - fediverse_auth_challenge::Model { | |
| 585 | - id, | |
| 586 | - nonce: "AAAA".to_string(), | |
| 587 | - handle: handle.to_string(), | |
| 588 | - domain: handle.split('@').next_back().unwrap_or("").to_string(), | |
| 589 | - requested_remote_id: None, | |
| 590 | - status: ChallengeStatus::Pending.as_str().to_string(), | |
| 591 | - redirect_url: None, | |
| 592 | - created_at: now, | |
| 593 | - expires_at: now + ChronoDuration::minutes(5), | |
| 594 | - consumed_at: None, | |
| 595 | - } | |
| 596 | - } | |
| 597 | - | |
| 598 | - fn test_auth_config() -> AuthConfig { | |
| 599 | - AuthConfig { | |
| 600 | - jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 601 | - .to_string(), | |
| 602 | - jwt_expiration_minutes: 15, | |
| 603 | - jwt_issuer: "rustwarden-test".to_string(), | |
| 604 | - refresh_token_expiration_days: 7, | |
| 605 | - remember_me_expiration_days: 30, | |
| 606 | - session_timeout_minutes: 30, | |
| 607 | - session_backend: crate::config::auth::SessionBackend::Memory, | |
| 608 | - redis_url: None, | |
| 609 | - rate_limit_login_per_minute: 5, | |
| 610 | - rate_limit_refresh_per_minute: 10, | |
| 611 | - rate_limit_general_per_minute: 100, | |
| 612 | - cookie_secure: false, | |
| 613 | - cookie_same_site: "Strict".to_string(), | |
| 614 | - } | |
| 615 | - } | |
| 616 | - | |
| 617 | - fn test_fediverse_config() -> FediverseConfig { | |
| 618 | - // SAFETY: these env tweaks happen on a Mutex-serialised path | |
| 619 | - // in production tests; here we just need a config built once. | |
| 620 | - unsafe { | |
| 621 | - std::env::set_var("FEDIVERSE_DOMAIN", "test.local"); | |
| 622 | - std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true"); | |
| 623 | - } | |
| 624 | - FediverseConfig::from_env().expect("test FediverseConfig must parse") | |
| 625 | - } | |
| 626 | - | |
| 627 | - // ─── Pure helpers ─────────────────────────────────────────────── | |
| 628 | - | |
| 629 | - #[test] | |
| 630 | - fn challenge_status_round_trips_through_string() { | |
| 631 | - for s in [ | |
| 632 | - ChallengeStatus::Pending, | |
| 633 | - ChallengeStatus::Approved, | |
| 634 | - ChallengeStatus::Rejected, | |
| 635 | - ChallengeStatus::Expired, | |
| 636 | - ChallengeStatus::Consumed, | |
| 637 | - ] { | |
| 638 | - assert_eq!(ChallengeStatus::parse(s.as_str()), s); | |
| 639 | - } | |
| 640 | - assert_eq!(ChallengeStatus::parse("garbage"), ChallengeStatus::Pending); | |
| 641 | - } | |
| 642 | - | |
| 643 | - #[test] | |
| 644 | - fn build_auth_request_payload_includes_nonce_and_target() { | |
| 645 | - let config = test_fediverse_config(); | |
| 646 | - let challenge = InitiatedChallenge { | |
| 647 | - id: Uuid::new_v4(), | |
| 648 | - nonce: "test-nonce-xxx".into(), | |
| 649 | - expires_at: Utc::now() + ChronoDuration::minutes(5), | |
| 650 | - }; | |
| 651 | - let payload = build_auth_request_payload(&config, &challenge, "alice@a.tld"); | |
| 652 | - assert_eq!(payload["nonce"], serde_json::json!("test-nonce-xxx")); | |
| 653 | - assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); | |
| 654 | - assert_eq!(payload["type"], serde_json::json!("AuthRequest")); | |
| 655 | - } | |
| 656 | - | |
| 657 | - #[test] | |
| 658 | - fn build_auth_request_payload_uses_fediverse_base_url() { | |
| 659 | - let config = test_fediverse_config(); | |
| 660 | - let challenge = InitiatedChallenge { | |
| 661 | - id: Uuid::new_v4(), | |
| 662 | - nonce: "n".into(), | |
| 663 | - expires_at: Utc::now(), | |
| 664 | - }; | |
| 665 | - let payload = build_auth_request_payload(&config, &challenge, "bob@b.tld"); | |
| 666 | - let id = payload["id"].as_str().unwrap(); | |
| 667 | - assert!( | |
| 668 | - id.contains(config.base_url().as_str()), | |
| 669 | - "id must embed base_url, got {id}", | |
| 670 | - ); | |
| 671 | - } | |
| 672 | - | |
| 673 | - // ─── DB-mocked ────────────────────────────────────────────────── | |
| 674 | - | |
| 675 | - #[tokio::test] | |
| 676 | - async fn initiate_persists_pending_challenge_with_fresh_nonce() { | |
| 677 | - let id = Uuid::new_v4(); | |
| 678 | - let row = fresh_pending(id, "alice@a.tld"); | |
| 679 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 680 | - .append_query_results([vec![row.clone()]]) | |
| 681 | - .into_connection(); | |
| 682 | - let initiated = FediverseAuthService::initiate( | |
| 683 | - &db, | |
| 684 | - InitiateRequest { | |
| 685 | - handle: "alice@a.tld".into(), | |
| 686 | - redirect_url: None, | |
| 687 | - }, | |
| 688 | - Duration::from_secs(300), | |
| 689 | - ) | |
| 690 | - .await | |
| 691 | - .expect("initiate ok"); | |
| 692 | - assert!(!initiated.nonce.is_empty()); | |
| 693 | - } | |
| 694 | - | |
| 695 | - #[test] | |
| 696 | - fn generate_nonce_is_unique_and_url_safe() { | |
| 697 | - // Direct test of the helper: the mock returns whatever we | |
| 698 | - // hand it back, so guarantee the **service-side** randomness | |
| 699 | - // by exercising the generator directly. | |
| 700 | - let a = generate_nonce(); | |
| 701 | - let b = generate_nonce(); | |
| 702 | - assert_ne!(a, b); | |
| 703 | - assert!( | |
| 704 | - a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), | |
| 705 | - "nonce must be URL-safe base64, got {a}", | |
| 706 | - ); | |
| 707 | - assert!(a.len() >= 32, "nonce length suspicious: {}", a.len()); | |
| 708 | - } | |
| 709 | - | |
| 710 | - #[tokio::test] | |
| 711 | - async fn record_decision_approve_marks_approved_and_records_remote_id() { | |
| 712 | - let id = Uuid::new_v4(); | |
| 713 | - let remote_id = Uuid::new_v4(); | |
| 714 | - let row = fresh_pending(id, "alice@a.tld"); | |
| 715 | - let mut after = row.clone(); | |
| 716 | - after.status = ChallengeStatus::Approved.as_str().to_string(); | |
| 717 | - after.requested_remote_id = Some(remote_id); | |
| 718 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 719 | - .append_query_results([vec![row]]) | |
| 720 | - .append_query_results([vec![after]]) | |
| 721 | - .append_exec_results([MockExecResult { | |
| 722 | - last_insert_id: 0, | |
| 723 | - rows_affected: 1, | |
| 724 | - }]) | |
| 725 | - .into_connection(); | |
| 726 | - | |
| 727 | - let updated = FediverseAuthService::record_decision( | |
| 728 | - &db, | |
| 729 | - id, | |
| 730 | - ChallengeDecision::Approve { | |
| 731 | - remote_identity_id: remote_id, | |
| 732 | - }, | |
| 733 | - ) | |
| 734 | - .await | |
| 735 | - .unwrap(); | |
| 736 | - assert_eq!(updated.status, "approved"); | |
| 737 | - assert_eq!(updated.requested_remote_id, Some(remote_id)); | |
| 738 | - } | |
| 739 | - | |
| 740 | - #[tokio::test] | |
| 741 | - async fn record_decision_reject_marks_rejected() { | |
| 742 | - let id = Uuid::new_v4(); | |
| 743 | - let row = fresh_pending(id, "alice@a.tld"); | |
| 744 | - let mut after = row.clone(); | |
| 745 | - after.status = ChallengeStatus::Rejected.as_str().to_string(); | |
| 746 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 747 | - .append_query_results([vec![row]]) | |
| 748 | - .append_query_results([vec![after]]) | |
| 749 | - .append_exec_results([MockExecResult { | |
| 750 | - last_insert_id: 0, | |
| 751 | - rows_affected: 1, | |
| 752 | - }]) | |
| 753 | - .into_connection(); | |
| 754 | - | |
| 755 | - let updated = | |
| 756 | - FediverseAuthService::record_decision(&db, id, ChallengeDecision::Reject) | |
| 757 | - .await | |
| 758 | - .unwrap(); | |
| 759 | - assert_eq!(updated.status, "rejected"); | |
| 760 | - } | |
| 761 | - | |
| 762 | - #[tokio::test] | |
| 763 | - async fn status_marks_expired_when_past_expires_at() { | |
| 764 | - let id = Uuid::new_v4(); | |
| 765 | - let mut row = fresh_pending(id, "alice@a.tld"); | |
| 766 | - row.expires_at = Utc::now() - ChronoDuration::minutes(1); | |
| 767 | - let mut after = row.clone(); | |
| 768 | - after.status = ChallengeStatus::Expired.as_str().to_string(); | |
| 769 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 770 | - .append_query_results([vec![row]]) | |
| 771 | - .append_query_results([vec![after]]) | |
| 772 | - .append_exec_results([MockExecResult { | |
| 773 | - last_insert_id: 0, | |
| 774 | - rows_affected: 1, | |
| 775 | - }]) | |
| 776 | - .into_connection(); | |
| 777 | - | |
| 778 | - let returned = FediverseAuthService::status(&db, id).await.unwrap(); | |
| 779 | - assert_eq!(returned.status, "expired"); | |
| 780 | - } | |
| 781 | - | |
| 782 | - #[tokio::test] | |
| 783 | - async fn status_returns_pending_when_not_decided_yet() { | |
| 784 | - let id = Uuid::new_v4(); | |
| 785 | - let row = fresh_pending(id, "alice@a.tld"); | |
| 786 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 787 | - .append_query_results([vec![row]]) | |
| 788 | - .into_connection(); | |
| 789 | - let returned = FediverseAuthService::status(&db, id).await.unwrap(); | |
| 790 | - assert_eq!(returned.status, "pending"); | |
| 791 | - } | |
| 792 | - | |
| 793 | - #[tokio::test] | |
| 794 | - async fn consume_rejects_pending_challenge() { | |
| 795 | - let id = Uuid::new_v4(); | |
| 796 | - let row = fresh_pending(id, "alice@a.tld"); | |
| 797 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 798 | - .append_query_results([vec![row]]) | |
| 799 | - .into_connection(); | |
| 800 | - let err = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 801 | - .await | |
| 802 | - .expect_err("must refuse pending"); | |
| 803 | - assert!(matches!(err, AppError::Validation(_))); | |
| 804 | - } | |
| 805 | - | |
| 806 | - #[tokio::test] | |
| 807 | - async fn consume_rejects_already_consumed_challenge() { | |
| 808 | - let id = Uuid::new_v4(); | |
| 809 | - let mut row = fresh_pending(id, "alice@a.tld"); | |
| 810 | - row.status = ChallengeStatus::Consumed.as_str().to_string(); | |
| 811 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 812 | - .append_query_results([vec![row]]) | |
| 813 | - .into_connection(); | |
| 814 | - let err = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 815 | - .await | |
| 816 | - .expect_err("must refuse consumed"); | |
| 817 | - assert!(matches!(err, AppError::Validation(_))); | |
| 818 | - } | |
| 819 | - | |
| 820 | - #[tokio::test] | |
| 821 | - async fn consume_emits_remote_jwt_for_approved_challenge() { | |
| 822 | - let id = Uuid::new_v4(); | |
| 823 | - let remote_id = Uuid::new_v4(); | |
| 824 | - let mut row = fresh_pending(id, "alice@a.tld"); | |
| 825 | - row.status = ChallengeStatus::Approved.as_str().to_string(); | |
| 826 | - row.requested_remote_id = Some(remote_id); | |
| 827 | - let mut after = row.clone(); | |
| 828 | - after.status = ChallengeStatus::Consumed.as_str().to_string(); | |
| 829 | - after.consumed_at = Some(Utc::now()); | |
| 830 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 831 | - .append_query_results([vec![row]]) | |
| 832 | - .append_query_results([vec![after]]) | |
| 833 | - .append_exec_results([MockExecResult { | |
| 834 | - last_insert_id: 0, | |
| 835 | - rows_affected: 1, | |
| 836 | - }]) | |
| 837 | - .into_connection(); | |
| 838 | - let session = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 839 | - .await | |
| 840 | - .unwrap(); | |
| 841 | - assert_eq!(session.remote_identity_id, remote_id); | |
| 842 | - assert_eq!(session.handle, "alice@a.tld"); | |
| 843 | - assert!(!session.jwt.is_empty()); | |
| 844 | - } | |
| 845 | - | |
| 846 | - // ─── Phase 2 (RFC 0006) — inbox enrichment ────────────────────── | |
| 847 | - | |
| 848 | - fn fresh_remote_identity(actor_url: &str, domain: &str) -> crate::models::remote_identity::Model { | |
| 849 | - let now = Utc::now(); | |
| 850 | - crate::models::remote_identity::Model { | |
| 851 | - id: Uuid::new_v4(), | |
| 852 | - ap_id: actor_url.to_string(), | |
| 853 | - username: "requester".to_string(), | |
| 854 | - domain: domain.to_string(), | |
| 855 | - display_name: None, | |
| 856 | - summary_html: None, | |
| 857 | - inbox_url: format!("{actor_url}/inbox"), | |
| 858 | - shared_inbox_url: None, | |
| 859 | - public_key_pem: "-----BEGIN PUBLIC KEY-----\nfoo\n-----END PUBLIC KEY-----\n" | |
| 860 | - .to_string(), | |
| 861 | - icon_url: None, | |
| 862 | - actor_type: "Person".to_string(), | |
| 863 | - raw_json: None, | |
| 864 | - last_fetched_at: now, | |
| 865 | - disabled_at: None, | |
| 866 | - created_at: now, | |
| 867 | - updated_at: now, | |
| 868 | - } | |
| 869 | - } | |
| 870 | - | |
| 871 | - #[tokio::test] | |
| 872 | - async fn persist_inbound_request_inserts_pending_with_remote_id() { | |
| 873 | - let requester = fresh_remote_identity("https://b.tld/", "b.tld"); | |
| 874 | - let requester_id = requester.id; | |
| 875 | - let inserted = fediverse_auth_challenge::Model { | |
| 876 | - id: Uuid::new_v4(), | |
| 877 | - nonce: "n".to_string(), | |
| 878 | - handle: "alice@a.tld".to_string(), | |
| 879 | - domain: "b.tld".to_string(), | |
| 880 | - requested_remote_id: Some(requester_id), | |
| 881 | - status: ChallengeStatus::Pending.as_str().to_string(), | |
| 882 | - redirect_url: None, | |
| 883 | - created_at: Utc::now(), | |
| 884 | - expires_at: Utc::now() + ChronoDuration::minutes(5), | |
| 885 | - consumed_at: None, | |
| 886 | - }; | |
| 887 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 888 | - .append_query_results([vec![requester]]) | |
| 889 | - .append_query_results([vec![inserted]]) | |
| 890 | - .append_exec_results([MockExecResult { | |
| 891 | - last_insert_id: 0, | |
| 892 | - rows_affected: 1, | |
| 893 | - }]) | |
| 894 | - .into_connection(); | |
| 895 | - | |
| 896 | - let body = serde_json::json!({ | |
| 897 | - "type": "AuthRequest", | |
| 898 | - "actor": "https://b.tld/", | |
| 899 | - "target": "@alice@a.tld", | |
| 900 | - "nonce": "n", | |
| 901 | - "expiresAt": (Utc::now() + ChronoDuration::minutes(5)).to_rfc3339(), | |
| 902 | - }); | |
| 903 | - | |
| 904 | - let row = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 905 | - .await | |
| 906 | - .expect("persist_inbound_request ok"); | |
| 907 | - assert_eq!(row.handle, "alice@a.tld"); | |
| 908 | - assert_eq!(row.domain, "b.tld"); | |
| 909 | - assert_eq!(row.requested_remote_id, Some(requester_id)); | |
| 910 | - } | |
| 911 | - | |
| 912 | - #[tokio::test] | |
| 913 | - async fn persist_inbound_request_rejects_wrong_idp_domain() { | |
| 914 | - let db: DatabaseConnection = | |
| 915 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 916 | - let body = serde_json::json!({ | |
| 917 | - "actor": "https://b.tld/", | |
| 918 | - "target": "@alice@evil.tld", | |
| 919 | - "nonce": "n", | |
| 920 | - }); | |
| 921 | - let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 922 | - .await | |
| 923 | - .expect_err("must reject mismatched IdP domain"); | |
| 924 | - assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 925 | - } | |
| 926 | - | |
| 927 | - #[tokio::test] | |
| 928 | - async fn persist_inbound_request_rejects_missing_actor() { | |
| 929 | - let db: DatabaseConnection = | |
| 930 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 931 | - let body = serde_json::json!({ | |
| 932 | - "target": "@alice@a.tld", | |
| 933 | - "nonce": "n", | |
| 934 | - }); | |
| 935 | - let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 936 | - .await | |
| 937 | - .expect_err("must reject missing actor"); | |
| 938 | - assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 939 | - } | |
| 940 | - | |
| 941 | - #[tokio::test] | |
| 942 | - async fn persist_inbound_request_rejects_unknown_requester() { | |
| 943 | - // No remote_identities row matches → service surfaces a clear | |
| 944 | - // Validation error rather than inserting a stray challenge. | |
| 945 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 946 | - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 947 | - .into_connection(); | |
| 948 | - let body = serde_json::json!({ | |
| 949 | - "actor": "https://unknown.tld/", | |
| 950 | - "target": "@alice@a.tld", | |
| 951 | - "nonce": "n", | |
| 952 | - }); | |
| 953 | - let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 954 | - .await | |
| 955 | - .expect_err("must reject unknown requester"); | |
| 956 | - assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 957 | - } | |
| 958 | -} |
D
crates/rustwarden-core/src/services/fediverse_invite_service.rs
+0
-543
@@ -1,543 +0,0 @@
| 1 | -//! Cross-instance team-membership invitations (RFC 0009). | |
| 2 | -//! | |
| 3 | -//! State machine `Pending → Accepted | Rejected | Revoked | Expired`. | |
| 4 | -//! On `Accept`, the service resolves the remote identity and inserts | |
| 5 | -//! a `team_members` row with `remote_identity_id` populated. The AP | |
| 6 | -//! activities themselves are produced by the `build_*_payload` helpers | |
| 7 | -//! in [`crate::services::fediverse_invite_payloads`] and dispatched by | |
| 8 | -//! the consumer through [`super::fediverse_outbound_service::FediverseOutboundService`]. | |
| 9 | -//! | |
| 10 | -//! Gated by `experimental-fediverse`. | |
| 11 | - | |
| 12 | -use chrono::{Duration as ChronoDuration, Utc}; | |
| 13 | -use sea_orm::{ | |
| 14 | - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, | |
| 15 | -}; | |
| 16 | -use std::time::Duration; | |
| 17 | -use uuid::Uuid; | |
| 18 | - | |
| 19 | -use crate::config::fediverse::FediverseConfig; | |
| 20 | -use crate::error::AppError; | |
| 21 | -use crate::models::{fediverse_pending_invite, team, team_member}; | |
| 22 | -use crate::services::team_service::TeamRole; | |
| 23 | - | |
| 24 | -/// Lifecycle of a [`fediverse_pending_invite::Model`] row. | |
| 25 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 26 | -pub enum InviteStatus { | |
| 27 | - /// Awaiting IdP decision. | |
| 28 | - Pending, | |
| 29 | - /// IdP user clicked Accept; the membership row exists. | |
| 30 | - Accepted, | |
| 31 | - /// IdP user clicked Reject. | |
| 32 | - Rejected, | |
| 33 | - /// Inviter cancelled the invitation. | |
| 34 | - Revoked, | |
| 35 | - /// `expires_at` reached without decision. | |
| 36 | - Expired, | |
| 37 | -} | |
| 38 | - | |
| 39 | -impl InviteStatus { | |
| 40 | - /// Wire form stored in the `status` column. | |
| 41 | - pub fn as_str(self) -> &'static str { | |
| 42 | - match self { | |
| 43 | - Self::Pending => "pending", | |
| 44 | - Self::Accepted => "accepted", | |
| 45 | - Self::Rejected => "rejected", | |
| 46 | - Self::Revoked => "revoked", | |
| 47 | - Self::Expired => "expired", | |
| 48 | - } | |
| 49 | - } | |
| 50 | - | |
| 51 | - /// Best-effort parse — unknown values fall back to `Pending`. | |
| 52 | - pub fn parse(raw: &str) -> Self { | |
| 53 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 54 | - "accepted" => Self::Accepted, | |
| 55 | - "rejected" => Self::Rejected, | |
| 56 | - "revoked" => Self::Revoked, | |
| 57 | - "expired" => Self::Expired, | |
| 58 | - _ => Self::Pending, | |
| 59 | - } | |
| 60 | - } | |
| 61 | -} | |
| 62 | - | |
| 63 | -/// Stateless service exposing the invite state machine. | |
| 64 | -pub struct FediverseInviteService; | |
| 65 | - | |
| 66 | -impl FediverseInviteService { | |
| 67 | - /// Persist a `pending` invitation. The caller is expected to | |
| 68 | - /// enqueue the AP `Invite{TeamMembership}` activity to the | |
| 69 | - /// remote inbox via `FediverseOutboundService::enqueue` using | |
| 70 | - /// [`build_invite_payload`]. | |
| 71 | - pub async fn invite_remote( | |
| 72 | - db: &DatabaseConnection, | |
| 73 | - team_id: Uuid, | |
| 74 | - invited_handle: &str, | |
| 75 | - role: TeamRole, | |
| 76 | - invited_by_user_id: Uuid, | |
| 77 | - ttl: Duration, | |
| 78 | - ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 79 | - let now = Utc::now(); | |
| 80 | - let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { | |
| 81 | - AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) | |
| 82 | - })?; | |
| 83 | - let id = Uuid::new_v4(); | |
| 84 | - let active = fediverse_pending_invite::ActiveModel { | |
| 85 | - id: Set(id), | |
| 86 | - activity_id: Set(format!("https://invite/{id}")), // tweak in handler with FediverseConfig if needed | |
| 87 | - team_id: Set(team_id), | |
| 88 | - invited_handle: Set(invited_handle.to_string()), | |
| 89 | - invited_remote_identity_id: Set(None), | |
| 90 | - role: Set(role.as_str().to_string()), | |
| 91 | - invited_by_user_id: Set(invited_by_user_id), | |
| 92 | - status: Set(InviteStatus::Pending.as_str().to_string()), | |
| 93 | - created_at: Set(now), | |
| 94 | - expires_at: Set(expires_at), | |
| 95 | - decided_at: Set(None), | |
| 96 | - }; | |
| 97 | - Ok(active.insert(db).await?) | |
| 98 | - } | |
| 99 | - | |
| 100 | - /// IdP-side accept: marks the invite `Accepted`, persists the | |
| 101 | - /// resolved `remote_identity_id`, and inserts the `team_members` | |
| 102 | - /// row with the remote sibling FK set. | |
| 103 | - pub async fn accept( | |
| 104 | - db: &DatabaseConnection, | |
| 105 | - invite_id: Uuid, | |
| 106 | - remote_identity_id: Uuid, | |
| 107 | - ) -> Result<team_member::Model, AppError> { | |
| 108 | - let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 109 | - .one(db) | |
| 110 | - .await? | |
| 111 | - .ok_or_else(|| { | |
| 112 | - AppError::NotFound(format!("Invite {invite_id} not found")) | |
| 113 | - })?; | |
| 114 | - | |
| 115 | - if invite.status != InviteStatus::Pending.as_str() { | |
| 116 | - return Err(AppError::Validation(format!( | |
| 117 | - "Cannot accept: invite is in state {}", | |
| 118 | - invite.status | |
| 119 | - ))); | |
| 120 | - } | |
| 121 | - | |
| 122 | - let team_id = invite.team_id; | |
| 123 | - let role = invite.role.clone(); | |
| 124 | - | |
| 125 | - let mut active: fediverse_pending_invite::ActiveModel = invite.into(); | |
| 126 | - active.status = Set(InviteStatus::Accepted.as_str().to_string()); | |
| 127 | - active.invited_remote_identity_id = Set(Some(remote_identity_id)); | |
| 128 | - active.decided_at = Set(Some(Utc::now())); | |
| 129 | - active.update(db).await?; | |
| 130 | - | |
| 131 | - let member = team_member::ActiveModel { | |
| 132 | - id: Set(Uuid::new_v4()), | |
| 133 | - team_id: Set(team_id), | |
| 134 | - // RFC 0018: PK relaxed; remote rows leave `user_id` NULL. | |
| 135 | - // The CHECK XOR guarantees exactly one principal column is | |
| 136 | - // populated; the unique partial index on | |
| 137 | - // (team_id, remote_identity_id) prevents duplicates. | |
| 138 | - user_id: Set(None), | |
| 139 | - role: Set(role), | |
| 140 | - joined_at: Set(Utc::now()), | |
| 141 | - remote_identity_id: Set(Some(remote_identity_id)), | |
| 142 | - }; | |
| 143 | - Ok(member.insert(db).await?) | |
| 144 | - } | |
| 145 | - | |
| 146 | - /// IdP-side reject. | |
| 147 | - pub async fn reject( | |
| 148 | - db: &DatabaseConnection, | |
| 149 | - invite_id: Uuid, | |
| 150 | - ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 151 | - Self::transition_terminal(db, invite_id, InviteStatus::Rejected, &[InviteStatus::Pending]).await | |
| 152 | - } | |
| 153 | - | |
| 154 | - /// Inviter-side revoke. Allowed both before and after acceptance | |
| 155 | - /// — when revoking an accepted invite, the membership row stays | |
| 156 | - /// in place and is removed by the lifecycle signal handler in | |
| 157 | - /// RFC 0010 (`Revoke{Membership}`). | |
| 158 | - pub async fn revoke( | |
| 159 | - db: &DatabaseConnection, | |
| 160 | - invite_id: Uuid, | |
| 161 | - ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 162 | - Self::transition_terminal( | |
| 163 | - db, | |
| 164 | - invite_id, | |
| 165 | - InviteStatus::Revoked, | |
| 166 | - &[InviteStatus::Pending, InviteStatus::Accepted], | |
| 167 | - ) | |
| 168 | - .await | |
| 169 | - } | |
| 170 | - | |
| 171 | - /// List currently-pending invites for a team. | |
| 172 | - pub async fn list_pending_for_team( | |
| 173 | - db: &DatabaseConnection, | |
| 174 | - team_id: Uuid, | |
| 175 | - ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { | |
| 176 | - Ok(fediverse_pending_invite::Entity::find() | |
| 177 | - .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) | |
| 178 | - .filter( | |
| 179 | - fediverse_pending_invite::Column::Status | |
| 180 | - .eq(InviteStatus::Pending.as_str()), | |
| 181 | - ) | |
| 182 | - .all(db) | |
| 183 | - .await?) | |
| 184 | - } | |
| 185 | - | |
| 186 | - /// List currently-pending invites addressed to a specific handle | |
| 187 | - /// (`@user@domain`). Used by the IdP-side endpoint | |
| 188 | - /// `GET /api/v1/fediverse/invites`. In `debug_local_federation` | |
| 189 | - /// mode both sides share the same DB; in production a real IdP | |
| 190 | - /// keeps a mirror table populated by its own inbox handler. | |
| 191 | - pub async fn list_pending_for_handle( | |
| 192 | - db: &DatabaseConnection, | |
| 193 | - handle: &str, | |
| 194 | - ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { | |
| 195 | - Ok(fediverse_pending_invite::Entity::find() | |
| 196 | - .filter(fediverse_pending_invite::Column::InvitedHandle.eq(handle)) | |
| 197 | - .filter( | |
| 198 | - fediverse_pending_invite::Column::Status | |
| 199 | - .eq(InviteStatus::Pending.as_str()), | |
| 200 | - ) | |
| 201 | - .all(db) | |
| 202 | - .await?) | |
| 203 | - } | |
| 204 | - | |
| 205 | - async fn transition_terminal( | |
| 206 | - db: &DatabaseConnection, | |
| 207 | - invite_id: Uuid, | |
| 208 | - target: InviteStatus, | |
| 209 | - from: &[InviteStatus], | |
| 210 | - ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 211 | - let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 212 | - .one(db) | |
| 213 | - .await? | |
| 214 | - .ok_or_else(|| { | |
| 215 | - AppError::NotFound(format!("Invite {invite_id} not found")) | |
| 216 | - })?; | |
| 217 | - let current = InviteStatus::parse(&invite.status); | |
| 218 | - if !from.contains(¤t) { | |
| 219 | - return Err(AppError::Validation(format!( | |
| 220 | - "Cannot transition: invite is in state {}", | |
| 221 | - invite.status | |
| 222 | - ))); | |
| 223 | - } | |
| 224 | - let mut active: fediverse_pending_invite::ActiveModel = invite.into(); | |
| 225 | - active.status = Set(target.as_str().to_string()); | |
| 226 | - active.decided_at = Set(Some(Utc::now())); | |
| 227 | - Ok(active.update(db).await?) | |
| 228 | - } | |
| 229 | -} | |
| 230 | - | |
| 231 | -/// Build the AP `Invite{TeamMembership}` JSON-LD payload. | |
| 232 | -pub fn build_invite_payload( | |
| 233 | - config: &FediverseConfig, | |
| 234 | - invite: &fediverse_pending_invite::Model, | |
| 235 | - team: &team::Model, | |
| 236 | -) -> serde_json::Value { | |
| 237 | - serde_json::json!({ | |
| 238 | - "@context": [ | |
| 239 | - "https://www.w3.org/ns/activitystreams", | |
| 240 | - "https://schema.rustwarden.org/team-membership/v1" | |
| 241 | - ], | |
| 242 | - "type": "Invite", | |
| 243 | - "id": invite.activity_id, | |
| 244 | - "actor": format!("{}", config.base_url()), | |
| 245 | - "object": { | |
| 246 | - "type": "TeamMembership", | |
| 247 | - "team": { | |
| 248 | - "id": format!("{}teams/{}", config.base_url(), team.id), | |
| 249 | - "name": team.name, | |
| 250 | - }, | |
| 251 | - "role": invite.role, | |
| 252 | - }, | |
| 253 | - "target": invite.invited_handle, | |
| 254 | - "expiresAt": invite.expires_at.to_rfc3339(), | |
| 255 | - }) | |
| 256 | -} | |
| 257 | - | |
| 258 | -/// Build the AP `Accept{Invite}` activity sent back from the IdP. | |
| 259 | -pub fn build_accept_invite_payload( | |
| 260 | - config: &FediverseConfig, | |
| 261 | - invite_activity_id: &str, | |
| 262 | -) -> serde_json::Value { | |
| 263 | - serde_json::json!({ | |
| 264 | - "@context": "https://www.w3.org/ns/activitystreams", | |
| 265 | - "type": "Accept", | |
| 266 | - "actor": format!("{}", config.base_url()), | |
| 267 | - "object": invite_activity_id, | |
| 268 | - }) | |
| 269 | -} | |
| 270 | - | |
| 271 | -/// Build the AP `Reject{Invite}` activity sent back from the IdP. | |
| 272 | -pub fn build_reject_invite_payload( | |
| 273 | - config: &FediverseConfig, | |
| 274 | - invite_activity_id: &str, | |
| 275 | -) -> serde_json::Value { | |
| 276 | - serde_json::json!({ | |
| 277 | - "@context": "https://www.w3.org/ns/activitystreams", | |
| 278 | - "type": "Reject", | |
| 279 | - "actor": format!("{}", config.base_url()), | |
| 280 | - "object": invite_activity_id, | |
| 281 | - }) | |
| 282 | -} | |
| 283 | - | |
| 284 | -/// Build the AP `Revoke{Membership}` activity for either side. | |
| 285 | -pub fn build_revoke_membership_payload( | |
| 286 | - config: &FediverseConfig, | |
| 287 | - membership_id: Uuid, | |
| 288 | - team_id: Uuid, | |
| 289 | -) -> serde_json::Value { | |
| 290 | - serde_json::json!({ | |
| 291 | - "@context": "https://www.w3.org/ns/activitystreams", | |
| 292 | - "type": "Revoke", | |
| 293 | - "actor": format!("{}", config.base_url()), | |
| 294 | - "object": { | |
| 295 | - "type": "TeamMembership", | |
| 296 | - "id": membership_id, | |
| 297 | - "team": format!("{}teams/{}", config.base_url(), team_id), | |
| 298 | - }, | |
| 299 | - }) | |
| 300 | -} | |
| 301 | - | |
| 302 | -#[cfg(test)] | |
| 303 | -mod tests { | |
| 304 | - use super::*; | |
| 305 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 306 | - | |
| 307 | - fn fresh_pending(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model { | |
| 308 | - let now = Utc::now(); | |
| 309 | - fediverse_pending_invite::Model { | |
| 310 | - id, | |
| 311 | - activity_id: format!("https://b.tld/invites/{id}"), | |
| 312 | - team_id, | |
| 313 | - invited_handle: "alice@a.tld".into(), | |
| 314 | - invited_remote_identity_id: None, | |
| 315 | - role: "member".into(), | |
| 316 | - invited_by_user_id: Uuid::new_v4(), | |
| 317 | - status: "pending".into(), | |
| 318 | - created_at: now, | |
| 319 | - expires_at: now + ChronoDuration::days(7), | |
| 320 | - decided_at: None, | |
| 321 | - } | |
| 322 | - } | |
| 323 | - | |
| 324 | - fn test_fediverse_config() -> FediverseConfig { | |
| 325 | - unsafe { | |
| 326 | - std::env::set_var("FEDIVERSE_DOMAIN", "test.local"); | |
| 327 | - std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true"); | |
| 328 | - } | |
| 329 | - FediverseConfig::from_env().expect("FediverseConfig parse") | |
| 330 | - } | |
| 331 | - | |
| 332 | - fn test_team(id: Uuid) -> team::Model { | |
| 333 | - team::Model { | |
| 334 | - id, | |
| 335 | - slug: "engineering".into(), | |
| 336 | - name: "Engineering".into(), | |
| 337 | - owner_id: Uuid::new_v4(), | |
| 338 | - created_at: Utc::now(), | |
| 339 | - } | |
| 340 | - } | |
| 341 | - | |
| 342 | - #[test] | |
| 343 | - fn invite_status_round_trips_through_string() { | |
| 344 | - for s in [ | |
| 345 | - InviteStatus::Pending, | |
| 346 | - InviteStatus::Accepted, | |
| 347 | - InviteStatus::Rejected, | |
| 348 | - InviteStatus::Revoked, | |
| 349 | - InviteStatus::Expired, | |
| 350 | - ] { | |
| 351 | - assert_eq!(InviteStatus::parse(s.as_str()), s); | |
| 352 | - } | |
| 353 | - assert_eq!(InviteStatus::parse("garbage"), InviteStatus::Pending); | |
| 354 | - } | |
| 355 | - | |
| 356 | - #[test] | |
| 357 | - fn build_invite_payload_includes_team_and_handle() { | |
| 358 | - let config = test_fediverse_config(); | |
| 359 | - let team_id = Uuid::new_v4(); | |
| 360 | - let invite = fresh_pending(Uuid::new_v4(), team_id); | |
| 361 | - let team = test_team(team_id); | |
| 362 | - let payload = build_invite_payload(&config, &invite, &team); | |
| 363 | - assert_eq!(payload["type"], serde_json::json!("Invite")); | |
| 364 | - assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); | |
| 365 | - assert_eq!(payload["object"]["role"], serde_json::json!("member")); | |
| 366 | - assert_eq!(payload["object"]["team"]["name"], serde_json::json!("Engineering")); | |
| 367 | - } | |
| 368 | - | |
| 369 | - #[test] | |
| 370 | - fn build_accept_invite_payload_references_invite_id() { | |
| 371 | - let config = test_fediverse_config(); | |
| 372 | - let payload = build_accept_invite_payload(&config, "https://b.tld/invites/x"); | |
| 373 | - assert_eq!(payload["type"], serde_json::json!("Accept")); | |
| 374 | - assert_eq!(payload["object"], serde_json::json!("https://b.tld/invites/x")); | |
| 375 | - } | |
| 376 | - | |
| 377 | - #[tokio::test] | |
| 378 | - async fn invite_remote_persists_pending_row() { | |
| 379 | - let row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); | |
| 380 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 381 | - .append_query_results([vec![row.clone()]]) | |
| 382 | - .into_connection(); | |
| 383 | - let inserted = FediverseInviteService::invite_remote( | |
| 384 | - &db, | |
| 385 | - row.team_id, | |
| 386 | - "alice@a.tld", | |
| 387 | - TeamRole::Member, | |
| 388 | - row.invited_by_user_id, | |
| 389 | - Duration::from_secs(7 * 86400), | |
| 390 | - ) | |
| 391 | - .await | |
| 392 | - .unwrap(); | |
| 393 | - assert_eq!(inserted.status, "pending"); | |
| 394 | - } | |
| 395 | - | |
| 396 | - #[tokio::test] | |
| 397 | - async fn invite_remote_uses_provided_role() { | |
| 398 | - let mut row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); | |
| 399 | - row.role = "admin".into(); | |
| 400 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 401 | - .append_query_results([vec![row.clone()]]) | |
| 402 | - .into_connection(); | |
| 403 | - let inserted = FediverseInviteService::invite_remote( | |
| 404 | - &db, | |
| 405 | - row.team_id, | |
| 406 | - "alice@a.tld", | |
| 407 | - TeamRole::Admin, | |
| 408 | - row.invited_by_user_id, | |
| 409 | - Duration::from_secs(86400), | |
| 410 | - ) | |
| 411 | - .await | |
| 412 | - .unwrap(); | |
| 413 | - assert_eq!(inserted.role, "admin"); | |
| 414 | - } | |
| 415 | - | |
| 416 | - #[tokio::test] | |
| 417 | - async fn accept_marks_invite_accepted_and_inserts_team_member() { | |
| 418 | - let invite_id = Uuid::new_v4(); | |
| 419 | - let team_id = Uuid::new_v4(); | |
| 420 | - let remote_id = Uuid::new_v4(); | |
| 421 | - let invite = fresh_pending(invite_id, team_id); | |
| 422 | - | |
| 423 | - let mut after_invite = invite.clone(); | |
| 424 | - after_invite.status = "accepted".into(); | |
| 425 | - after_invite.invited_remote_identity_id = Some(remote_id); | |
| 426 | - after_invite.decided_at = Some(Utc::now()); | |
| 427 | - | |
| 428 | - let now = Utc::now(); | |
| 429 | - let new_member = team_member::Model { | |
| 430 | - id: Uuid::new_v4(), | |
| 431 | - team_id, | |
| 432 | - user_id: None, | |
| 433 | - role: "member".into(), | |
| 434 | - joined_at: now, | |
| 435 | - remote_identity_id: Some(remote_id), | |
| 436 | - }; | |
| 437 | - | |
| 438 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 439 | - // 1. SELECT invite | |
| 440 | - .append_query_results([vec![invite]]) | |
| 441 | - // 2. SELECT issued by .update() on invite | |
| 442 | - .append_query_results([vec![after_invite]]) | |
| 443 | - // 3. INSERT into team_members → returned row | |
| 444 | - .append_query_results([vec![new_member.clone()]]) | |
| 445 | - .append_exec_results([ | |
| 446 | - MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 447 | - MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 448 | - ]) | |
| 449 | - .into_connection(); | |
| 450 | - | |
| 451 | - let member = FediverseInviteService::accept(&db, invite_id, remote_id) | |
| 452 | - .await | |
| 453 | - .unwrap(); | |
| 454 | - assert_eq!(member.remote_identity_id, Some(remote_id)); | |
| 455 | - assert_eq!(member.team_id, team_id); | |
| 456 | - } | |
| 457 | - | |
| 458 | - #[tokio::test] | |
| 459 | - async fn accept_refuses_non_pending_invite() { | |
| 460 | - let invite_id = Uuid::new_v4(); | |
| 461 | - let mut invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 462 | - invite.status = "rejected".into(); | |
| 463 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 464 | - .append_query_results([vec![invite]]) | |
| 465 | - .into_connection(); | |
| 466 | - let err = FediverseInviteService::accept(&db, invite_id, Uuid::new_v4()) | |
| 467 | - .await | |
| 468 | - .expect_err("must refuse non-pending"); | |
| 469 | - assert!(matches!(err, AppError::Validation(_))); | |
| 470 | - } | |
| 471 | - | |
| 472 | - #[tokio::test] | |
| 473 | - async fn reject_marks_rejected() { | |
| 474 | - let invite_id = Uuid::new_v4(); | |
| 475 | - let invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 476 | - let mut after = invite.clone(); | |
| 477 | - after.status = "rejected".into(); | |
| 478 | - after.decided_at = Some(Utc::now()); | |
| 479 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 480 | - .append_query_results([vec![invite]]) | |
| 481 | - .append_query_results([vec![after]]) | |
| 482 | - .append_exec_results([MockExecResult { | |
| 483 | - last_insert_id: 0, | |
| 484 | - rows_affected: 1, | |
| 485 | - }]) | |
| 486 | - .into_connection(); | |
| 487 | - let updated = FediverseInviteService::reject(&db, invite_id).await.unwrap(); | |
| 488 | - assert_eq!(updated.status, "rejected"); | |
| 489 | - } | |
| 490 | - | |
| 491 | - #[tokio::test] | |
| 492 | - async fn revoke_marks_revoked_when_pending() { | |
| 493 | - let invite_id = Uuid::new_v4(); | |
| 494 | - let invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 495 | - let mut after = invite.clone(); | |
| 496 | - after.status = "revoked".into(); | |
| 497 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 498 | - .append_query_results([vec![invite]]) | |
| 499 | - .append_query_results([vec![after]]) | |
| 500 | - .append_exec_results([MockExecResult { | |
| 501 | - last_insert_id: 0, | |
| 502 | - rows_affected: 1, | |
| 503 | - }]) | |
| 504 | - .into_connection(); | |
| 505 | - let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap(); | |
| 506 | - assert_eq!(updated.status, "revoked"); | |
| 507 | - } | |
| 508 | - | |
| 509 | - #[tokio::test] | |
| 510 | - async fn revoke_marks_revoked_when_already_accepted() { | |
| 511 | - let invite_id = Uuid::new_v4(); | |
| 512 | - let mut invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 513 | - invite.status = "accepted".into(); | |
| 514 | - invite.invited_remote_identity_id = Some(Uuid::new_v4()); | |
| 515 | - let mut after = invite.clone(); | |
| 516 | - after.status = "revoked".into(); | |
| 517 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 518 | - .append_query_results([vec![invite]]) | |
| 519 | - .append_query_results([vec![after]]) | |
| 520 | - .append_exec_results([MockExecResult { | |
| 521 | - last_insert_id: 0, | |
| 522 | - rows_affected: 1, | |
| 523 | - }]) | |
| 524 | - .into_connection(); | |
| 525 | - let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap(); | |
| 526 | - assert_eq!(updated.status, "revoked"); | |
| 527 | - } | |
| 528 | - | |
| 529 | - #[tokio::test] | |
| 530 | - async fn list_pending_for_team_returns_only_pending() { | |
| 531 | - let team_id = Uuid::new_v4(); | |
| 532 | - let p1 = fresh_pending(Uuid::new_v4(), team_id); | |
| 533 | - let p2 = fresh_pending(Uuid::new_v4(), team_id); | |
| 534 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 535 | - .append_query_results([vec![p1.clone(), p2.clone()]]) | |
| 536 | - .into_connection(); | |
| 537 | - let result = FediverseInviteService::list_pending_for_team(&db, team_id) | |
| 538 | - .await | |
| 539 | - .unwrap(); | |
| 540 | - assert_eq!(result.len(), 2); | |
| 541 | - assert!(result.iter().all(|r| r.status == "pending")); | |
| 542 | - } | |
| 543 | -} |
D
crates/rustwarden-core/src/services/fediverse_outbound_service.rs
+0
-589
@@ -1,589 +0,0 @@
| 1 | -//! Outbound ActivityPub delivery queue + worker (RFC 0011). | |
| 2 | -//! | |
| 3 | -//! Persistent journal of activities that must be delivered to remote | |
| 4 | -//! inboxes (auth `Accept{AuthRequest}`, invitations, lifecycle signals). | |
| 5 | -//! The transport layer is abstracted behind [`OutboundDelivery`] so | |
| 6 | -//! that RFC 0010 can plug in HTTP-Sig signing without disturbing the | |
| 7 | -//! queue/worker plumbing. | |
| 8 | -//! | |
| 9 | -//! Gated by `experimental-fediverse`. | |
| 10 | - | |
| 11 | -use async_trait::async_trait; | |
| 12 | -use chrono::Utc; | |
| 13 | -use sea_orm::{ | |
| 14 | - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ExprTrait, QueryFilter, | |
| 15 | - QueryOrder, Set, | |
| 16 | -}; | |
| 17 | -use std::time::Duration; | |
| 18 | -use uuid::Uuid; | |
| 19 | - | |
| 20 | -use crate::error::AppError; | |
| 21 | -use crate::models::fediverse_outbound_queue; | |
| 22 | - | |
| 23 | -/// Lifecycle of an outbound queue row. | |
| 24 | -/// | |
| 25 | -/// In the Phase-2 slice livered by RFC 0011 only `Pending → Delivered` | |
| 26 | -/// and `Pending → Failed` transitions occur. RFC 0010 introduces the | |
| 27 | -/// retry/back-off ladder that uses `Failed → Pending` and the terminal | |
| 28 | -/// `Failed → Dead`. | |
| 29 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 30 | -pub enum OutboundStatus { | |
| 31 | - /// Eligible for delivery. | |
| 32 | - Pending, | |
| 33 | - /// Successfully delivered. | |
| 34 | - Delivered, | |
| 35 | - /// Last attempt failed; not (yet) eligible for retry. | |
| 36 | - Failed, | |
| 37 | - /// Reached `max_attempts`, no further attempts will be made. | |
| 38 | - Dead, | |
| 39 | -} | |
| 40 | - | |
| 41 | -impl OutboundStatus { | |
| 42 | - /// RFC 0010 retry ladder. The returned offset is added to `now` | |
| 43 | - /// to compute `next_retry_at` after a failed delivery. Sequence: | |
| 44 | - /// 5 min → 30 min → 2 h → 6 h → 24 h → 48 h, capped at 48 h. | |
| 45 | - /// `attempts` is the **post-increment** value (number of failed | |
| 46 | - /// attempts so far). | |
| 47 | - pub fn next_retry_offset(attempts: i32) -> std::time::Duration { | |
| 48 | - const LADDER_SECS: &[u64] = &[ | |
| 49 | - 5 * 60, | |
| 50 | - 30 * 60, | |
| 51 | - 2 * 3600, | |
| 52 | - 6 * 3600, | |
| 53 | - 24 * 3600, | |
| 54 | - 48 * 3600, | |
| 55 | - ]; | |
| 56 | - let bounded = std::cmp::Ord::max(attempts, 1) as usize - 1; | |
| 57 | - let idx = bounded.min(LADDER_SECS.len() - 1); | |
| 58 | - std::time::Duration::from_secs(LADDER_SECS[idx]) | |
| 59 | - } | |
| 60 | - | |
| 61 | - /// Wire form stored in the `status` column. | |
| 62 | - pub fn as_str(self) -> &'static str { | |
| 63 | - match self { | |
| 64 | - Self::Pending => "pending", | |
| 65 | - Self::Delivered => "delivered", | |
| 66 | - Self::Failed => "failed", | |
| 67 | - Self::Dead => "dead", | |
| 68 | - } | |
| 69 | - } | |
| 70 | - | |
| 71 | - /// Best-effort parse — unknown values fall back to `Pending` so a | |
| 72 | - /// data corruption never silently drops a row. | |
| 73 | - pub fn parse(raw: &str) -> Self { | |
| 74 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 75 | - "delivered" => Self::Delivered, | |
| 76 | - "failed" => Self::Failed, | |
| 77 | - "dead" => Self::Dead, | |
| 78 | - _ => Self::Pending, | |
| 79 | - } | |
| 80 | - } | |
| 81 | -} | |
| 82 | - | |
| 83 | -/// Caller-supplied metadata used to enqueue an activity for delivery. | |
| 84 | -#[derive(Debug, Clone)] | |
| 85 | -pub struct EnqueueRequest { | |
| 86 | - /// Canonical AP `id` of the activity (e.g. `https://a.tld/activities/uuid`). | |
| 87 | - pub activity_id: String, | |
| 88 | - /// Pre-rendered JSON-LD body that will be POSTed to `target_inbox`. | |
| 89 | - pub payload_json: String, | |
| 90 | - /// Inbox URL. | |
| 91 | - pub target_inbox: String, | |
| 92 | - /// Domain owning the inbox (used by trust checks). | |
| 93 | - pub target_domain: String, | |
| 94 | - /// Local actor on whose behalf the activity is signed. Nullable | |
| 95 | - /// while only `NoopDelivery` is wired; required by RFC 0010. | |
| 96 | - pub actor_user_id: Option<Uuid>, | |
| 97 | -} | |
| 98 | - | |
| 99 | -/// Transport layer abstraction. | |
| 100 | -/// | |
| 101 | -/// The Phase-2 slice ships only [`NoopDelivery`]. RFC 0010 will swap in | |
| 102 | -/// `HttpSignedDelivery` that uses RFC 0005 keys to sign requests. | |
| 103 | -#[async_trait] | |
| 104 | -pub trait OutboundDelivery: Send + Sync { | |
| 105 | - /// Attempt to deliver `item` to its `target_inbox`. Returning `Ok` | |
| 106 | - /// promotes the row to `Delivered`; returning `Err` flips it to | |
| 107 | - /// `Failed` and increments `attempts`. | |
| 108 | - async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError>; | |
| 109 | -} | |
| 110 | - | |
| 111 | -/// No-op transport for the slice livered by RFC 0011: pretends every | |
| 112 | -/// delivery succeeded so the queue/worker plumbing can be exercised | |
| 113 | -/// end-to-end without an HTTP stack. Production deployments must swap | |
| 114 | -/// for `HttpSignedDelivery` once RFC 0010 lands. | |
| 115 | -pub struct NoopDelivery; | |
| 116 | - | |
| 117 | -#[async_trait] | |
| 118 | -impl OutboundDelivery for NoopDelivery { | |
| 119 | - async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError> { | |
| 120 | - tracing::debug!( | |
| 121 | - target = %item.target_inbox, | |
| 122 | - activity = %item.activity_id, | |
| 123 | - "NoopDelivery: pretending delivery succeeded", | |
| 124 | - ); | |
| 125 | - Ok(()) | |
| 126 | - } | |
| 127 | -} | |
| 128 | - | |
| 129 | -/// Stateless service exposing enqueue + drain operations on the | |
| 130 | -/// outbound queue. | |
| 131 | -pub struct FediverseOutboundService; | |
| 132 | - | |
| 133 | -impl FediverseOutboundService { | |
| 134 | - /// Persist a new `pending` row. | |
| 135 | - pub async fn enqueue( | |
| 136 | - db: &DatabaseConnection, | |
| 137 | - request: EnqueueRequest, | |
| 138 | - ) -> Result<fediverse_outbound_queue::Model, AppError> { | |
| 139 | - let now = Utc::now(); | |
| 140 | - let model = fediverse_outbound_queue::ActiveModel { | |
| 141 | - id: Set(Uuid::new_v4()), | |
| 142 | - activity_id: Set(request.activity_id), | |
| 143 | - payload_json: Set(request.payload_json), | |
| 144 | - target_inbox: Set(request.target_inbox), | |
| 145 | - target_domain: Set(request.target_domain), | |
| 146 | - status: Set(OutboundStatus::Pending.as_str().to_string()), | |
| 147 | - attempts: Set(0), | |
| 148 | - max_attempts: Set(8), | |
| 149 | - last_error: Set(None), | |
| 150 | - next_retry_at: Set(None), | |
| 151 | - created_at: Set(now), | |
| 152 | - delivered_at: Set(None), | |
| 153 | - actor_user_id: Set(request.actor_user_id), | |
| 154 | - }; | |
| 155 | - Ok(model.insert(db).await?) | |
| 156 | - } | |
| 157 | - | |
| 158 | - /// Pick one eligible row, hand it to `transport`, and persist the | |
| 159 | - /// outcome. Returns the updated row, or `None` if the queue is | |
| 160 | - /// empty. | |
| 161 | - pub async fn drain_one<T: OutboundDelivery>( | |
| 162 | - db: &DatabaseConnection, | |
| 163 | - transport: &T, | |
| 164 | - ) -> Result<Option<fediverse_outbound_queue::Model>, AppError> { | |
| 165 | - let now = Utc::now(); | |
| 166 | - let candidate = fediverse_outbound_queue::Entity::find() | |
| 167 | - .filter( | |
| 168 | - fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str()), | |
| 169 | - ) | |
| 170 | - .filter( | |
| 171 | - fediverse_outbound_queue::Column::NextRetryAt | |
| 172 | - .is_null() | |
| 173 | - .or(fediverse_outbound_queue::Column::NextRetryAt.lte(now)), | |
| 174 | - ) | |
| 175 | - .order_by_asc(fediverse_outbound_queue::Column::CreatedAt) | |
| 176 | - .one(db) | |
| 177 | - .await?; | |
| 178 | - | |
| 179 | - let Some(model) = candidate else { | |
| 180 | - return Ok(None); | |
| 181 | - }; | |
| 182 | - | |
| 183 | - let delivery = transport.deliver(&model).await; | |
| 184 | - let next_attempts = model.attempts + 1; | |
| 185 | - let max_attempts = model.max_attempts; | |
| 186 | - let model_for_update = model.clone(); | |
| 187 | - let mut active: fediverse_outbound_queue::ActiveModel = model_for_update.into(); | |
| 188 | - active.attempts = Set(next_attempts); | |
| 189 | - match &delivery { | |
| 190 | - Ok(()) => { | |
| 191 | - active.status = Set(OutboundStatus::Delivered.as_str().to_string()); | |
| 192 | - active.delivered_at = Set(Some(Utc::now())); | |
| 193 | - active.last_error = Set(None); | |
| 194 | - active.next_retry_at = Set(None); | |
| 195 | - } | |
| 196 | - Err(err) => { | |
| 197 | - active.last_error = Set(Some(err.to_string())); | |
| 198 | - if next_attempts >= max_attempts { | |
| 199 | - // RFC 0010: terminal — no further retry. | |
| 200 | - active.status = Set(OutboundStatus::Dead.as_str().to_string()); | |
| 201 | - active.next_retry_at = Set(None); | |
| 202 | - } else { | |
| 203 | - // RFC 0010: stay `pending` and gate on `next_retry_at` | |
| 204 | - // so the partial-index scan ignores the row until the | |
| 205 | - // backoff has elapsed. | |
| 206 | - let offset = OutboundStatus::next_retry_offset(next_attempts); | |
| 207 | - let next = Utc::now() | |
| 208 | - + chrono::Duration::from_std(offset).map_err(|e| { | |
| 209 | - AppError::Internal(format!( | |
| 210 | - "retry offset out of range for chrono::Duration: {e}", | |
| 211 | - )) | |
| 212 | - })?; | |
| 213 | - active.status = Set(OutboundStatus::Pending.as_str().to_string()); | |
| 214 | - active.next_retry_at = Set(Some(next)); | |
| 215 | - } | |
| 216 | - } | |
| 217 | - } | |
| 218 | - let updated = active.update(db).await?; | |
| 219 | - | |
| 220 | - // RFC 0019: structured tracing events — drive Prometheus / Loki / | |
| 221 | - // OTEL exporters without coupling rustwarden-core to a metrics | |
| 222 | - // crate. Gated by `app_settings.fediverse_observability_enabled` | |
| 223 | - // (default `false`) — opt-in to avoid log noise on instances | |
| 224 | - // that pipe nothing to an observability backend. | |
| 225 | - let observability = | |
| 226 | - is_fediverse_observability_enabled(db).await.unwrap_or(false); | |
| 227 | - if observability { | |
| 228 | - match (&delivery, updated.status.as_str()) { | |
| 229 | - (Ok(()), _) => { | |
| 230 | - tracing::info!( | |
| 231 | - target: "rustwarden::fediverse::outbound", | |
| 232 | - activity_id = %updated.activity_id, | |
| 233 | - target_inbox = %updated.target_inbox, | |
| 234 | - target_domain = %updated.target_domain, | |
| 235 | - attempts = updated.attempts, | |
| 236 | - event = "delivered", | |
| 237 | - ); | |
| 238 | - } | |
| 239 | - (Err(err), status) if status == OutboundStatus::Dead.as_str() => { | |
| 240 | - tracing::error!( | |
| 241 | - target: "rustwarden::fediverse::outbound", | |
| 242 | - activity_id = %updated.activity_id, | |
| 243 | - target_domain = %updated.target_domain, | |
| 244 | - last_error = %err, | |
| 245 | - attempts = updated.attempts, | |
| 246 | - max_attempts = updated.max_attempts, | |
| 247 | - event = "dead", | |
| 248 | - ); | |
| 249 | - } | |
| 250 | - (Err(err), _) => { | |
| 251 | - tracing::warn!( | |
| 252 | - target: "rustwarden::fediverse::outbound", | |
| 253 | - activity_id = %updated.activity_id, | |
| 254 | - target_domain = %updated.target_domain, | |
| 255 | - last_error = %err, | |
| 256 | - attempts = updated.attempts, | |
| 257 | - next_retry_at = ?updated.next_retry_at, | |
| 258 | - event = "deferred", | |
| 259 | - ); | |
| 260 | - } | |
| 261 | - } | |
| 262 | - } | |
| 263 | - | |
| 264 | - Ok(Some(updated)) | |
| 265 | - } | |
| 266 | -} | |
| 267 | - | |
| 268 | -/// RFC 0019 + S4 observability toggle — `app_settings.fediverse_observability_enabled`, | |
| 269 | -/// default **false**. When `false`, fediverse routes/services skip the | |
| 270 | -/// structured `tracing::info!` / `warn!` / `error!` emit-points used for | |
| 271 | -/// metric aggregation. Cheap (1 SELECT by key) ; cached by the upstream | |
| 272 | -/// `AppSettingsService` if the consumer wires its standard cache. | |
| 273 | -pub async fn is_fediverse_observability_enabled( | |
| 274 | - db: &DatabaseConnection, | |
| 275 | -) -> Result<bool, AppError> { | |
| 276 | - crate::services::app_settings_service::AppSettingsService::get_bool( | |
| 277 | - db, | |
| 278 | - "fediverse_observability_enabled", | |
| 279 | - false, | |
| 280 | - ) | |
| 281 | - .await | |
| 282 | -} | |
| 283 | - | |
| 284 | -/// Background loop that periodically drains the queue. | |
| 285 | -/// | |
| 286 | -/// Spawned by `RustwardenBuilder::with_fediverse()` (RFC 0011 hooks | |
| 287 | -/// the spawn into `with_fediverse` in a follow-up patch — RFC 0006 | |
| 288 | -/// will be the first consumer and triggers the spawn explicitly). | |
| 289 | -pub struct OutboundWorker<T: OutboundDelivery> { | |
| 290 | - db: DatabaseConnection, | |
| 291 | - transport: T, | |
| 292 | - poll_interval: Duration, | |
| 293 | -} | |
| 294 | - | |
| 295 | -impl<T: OutboundDelivery> OutboundWorker<T> { | |
| 296 | - /// Build a worker that polls every `poll_interval`. | |
| 297 | - pub fn new(db: DatabaseConnection, transport: T, poll_interval: Duration) -> Self { | |
| 298 | - Self { | |
| 299 | - db, | |
| 300 | - transport, | |
| 301 | - poll_interval, | |
| 302 | - } | |
| 303 | - } | |
| 304 | - | |
| 305 | - /// Run the polling loop forever. Errors during a single delivery | |
| 306 | - /// are logged but do not stop the loop. Cancellation is achieved | |
| 307 | - /// by cancelling the surrounding task. | |
| 308 | - pub async fn run(self) { | |
| 309 | - loop { | |
| 310 | - match FediverseOutboundService::drain_one(&self.db, &self.transport).await { | |
| 311 | - Ok(Some(_)) => continue, // drain greedily while items exist | |
| 312 | - Ok(None) => tokio::time::sleep(self.poll_interval).await, | |
| 313 | - Err(err) => { | |
| 314 | - tracing::error!( | |
| 315 | - target: "rustwarden::fediverse::outbound", | |
| 316 | - error = %err, | |
| 317 | - event = "drain_failed", | |
| 318 | - ); | |
| 319 | - tokio::time::sleep(self.poll_interval).await; | |
| 320 | - } | |
| 321 | - } | |
| 322 | - } | |
| 323 | - } | |
| 324 | -} | |
| 325 | - | |
| 326 | -#[cfg(test)] | |
| 327 | -mod tests { | |
| 328 | - use super::*; | |
| 329 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 330 | - | |
| 331 | - fn pending_row(id: Uuid) -> fediverse_outbound_queue::Model { | |
| 332 | - let now = Utc::now(); | |
| 333 | - fediverse_outbound_queue::Model { | |
| 334 | - id, | |
| 335 | - activity_id: format!("https://a.tld/activities/{id}"), | |
| 336 | - payload_json: "{}".to_string(), | |
| 337 | - target_inbox: "https://b.tld/inbox".to_string(), | |
| 338 | - target_domain: "b.tld".to_string(), | |
| 339 | - status: "pending".to_string(), | |
| 340 | - attempts: 0, | |
| 341 | - max_attempts: 8, | |
| 342 | - last_error: None, | |
| 343 | - next_retry_at: None, | |
| 344 | - created_at: now, | |
| 345 | - delivered_at: None, | |
| 346 | - actor_user_id: None, | |
| 347 | - } | |
| 348 | - } | |
| 349 | - | |
| 350 | - struct ErringDelivery; | |
| 351 | - | |
| 352 | - #[async_trait] | |
| 353 | - impl OutboundDelivery for ErringDelivery { | |
| 354 | - async fn deliver( | |
| 355 | - &self, | |
| 356 | - _item: &fediverse_outbound_queue::Model, | |
| 357 | - ) -> Result<(), AppError> { | |
| 358 | - Err(AppError::Internal("simulated transport failure".into())) | |
| 359 | - } | |
| 360 | - } | |
| 361 | - | |
| 362 | - #[test] | |
| 363 | - fn outbound_status_round_trips_through_string() { | |
| 364 | - for s in [ | |
| 365 | - OutboundStatus::Pending, | |
| 366 | - OutboundStatus::Delivered, | |
| 367 | - OutboundStatus::Failed, | |
| 368 | - OutboundStatus::Dead, | |
| 369 | - ] { | |
| 370 | - assert_eq!(OutboundStatus::parse(s.as_str()), s); | |
| 371 | - } | |
| 372 | - // Unknown wire value falls back safely. | |
| 373 | - assert_eq!(OutboundStatus::parse("garbage"), OutboundStatus::Pending); | |
| 374 | - } | |
| 375 | - | |
| 376 | - #[test] | |
| 377 | - fn enqueue_request_clone_works() { | |
| 378 | - let r = EnqueueRequest { | |
| 379 | - activity_id: "act".into(), | |
| 380 | - payload_json: "{}".into(), | |
| 381 | - target_inbox: "ib".into(), | |
| 382 | - target_domain: "d".into(), | |
| 383 | - actor_user_id: None, | |
| 384 | - }; | |
| 385 | - let c = r.clone(); | |
| 386 | - assert_eq!(c.activity_id, "act"); | |
| 387 | - } | |
| 388 | - | |
| 389 | - #[tokio::test] | |
| 390 | - async fn enqueue_persists_pending_row() { | |
| 391 | - let id = Uuid::new_v4(); | |
| 392 | - let row = pending_row(id); | |
| 393 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 394 | - .append_query_results([vec![row.clone()]]) | |
| 395 | - .into_connection(); | |
| 396 | - | |
| 397 | - let inserted = FediverseOutboundService::enqueue( | |
| 398 | - &db, | |
| 399 | - EnqueueRequest { | |
| 400 | - activity_id: row.activity_id.clone(), | |
| 401 | - payload_json: row.payload_json.clone(), | |
| 402 | - target_inbox: row.target_inbox.clone(), | |
| 403 | - target_domain: row.target_domain.clone(), | |
| 404 | - actor_user_id: None, | |
| 405 | - }, | |
| 406 | - ) | |
| 407 | - .await | |
| 408 | - .expect("enqueue ok"); | |
| 409 | - assert_eq!(inserted.status, "pending"); | |
| 410 | - assert_eq!(inserted.attempts, 0); | |
| 411 | - } | |
| 412 | - | |
| 413 | - #[tokio::test] | |
| 414 | - async fn enqueue_uses_provided_activity_id() { | |
| 415 | - let id = Uuid::new_v4(); | |
| 416 | - let mut row = pending_row(id); | |
| 417 | - row.activity_id = "https://example.test/specific-id".into(); | |
| 418 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 419 | - .append_query_results([vec![row.clone()]]) | |
| 420 | - .into_connection(); | |
| 421 | - | |
| 422 | - let inserted = FediverseOutboundService::enqueue( | |
| 423 | - &db, | |
| 424 | - EnqueueRequest { | |
| 425 | - activity_id: row.activity_id.clone(), | |
| 426 | - payload_json: "{}".into(), | |
| 427 | - target_inbox: row.target_inbox.clone(), | |
| 428 | - target_domain: row.target_domain.clone(), | |
| 429 | - actor_user_id: None, | |
| 430 | - }, | |
| 431 | - ) | |
| 432 | - .await | |
| 433 | - .unwrap(); | |
| 434 | - assert_eq!(inserted.activity_id, "https://example.test/specific-id"); | |
| 435 | - } | |
| 436 | - | |
| 437 | - #[tokio::test] | |
| 438 | - async fn drain_one_returns_none_when_empty() { | |
| 439 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 440 | - .append_query_results([Vec::<fediverse_outbound_queue::Model>::new()]) | |
| 441 | - .into_connection(); | |
| 442 | - let result = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 443 | - .await | |
| 444 | - .unwrap(); | |
| 445 | - assert!(result.is_none()); | |
| 446 | - } | |
| 447 | - | |
| 448 | - #[tokio::test] | |
| 449 | - async fn drain_one_marks_delivered_on_success() { | |
| 450 | - let id = Uuid::new_v4(); | |
| 451 | - let row = pending_row(id); | |
| 452 | - let mut after = row.clone(); | |
| 453 | - after.status = "delivered".into(); | |
| 454 | - after.attempts = 1; | |
| 455 | - after.delivered_at = Some(Utc::now()); | |
| 456 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 457 | - // SELECT for candidate | |
| 458 | - .append_query_results([vec![row.clone()]]) | |
| 459 | - // Row returned by .update() | |
| 460 | - .append_query_results([vec![after]]) | |
| 461 | - .append_exec_results([MockExecResult { | |
| 462 | - last_insert_id: 0, | |
| 463 | - rows_affected: 1, | |
| 464 | - }]) | |
| 465 | - .into_connection(); | |
| 466 | - | |
| 467 | - let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 468 | - .await | |
| 469 | - .unwrap() | |
| 470 | - .expect("row processed"); | |
| 471 | - assert_eq!(updated.status, "delivered"); | |
| 472 | - assert_eq!(updated.attempts, 1); | |
| 473 | - assert!(updated.last_error.is_none()); | |
| 474 | - } | |
| 475 | - | |
| 476 | - #[tokio::test] | |
| 477 | - async fn drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts() { | |
| 478 | - // RFC 0010: a single failure on a row with attempts < max | |
| 479 | - // keeps the row `pending` but gated on `next_retry_at`. | |
| 480 | - let id = Uuid::new_v4(); | |
| 481 | - let row = pending_row(id); | |
| 482 | - let mut after = row.clone(); | |
| 483 | - after.status = "pending".into(); | |
| 484 | - after.attempts = 1; | |
| 485 | - after.last_error = Some("simulated transport failure".into()); | |
| 486 | - after.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); | |
| 487 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 488 | - .append_query_results([vec![row.clone()]]) | |
| 489 | - .append_query_results([vec![after]]) | |
| 490 | - .append_exec_results([MockExecResult { | |
| 491 | - last_insert_id: 0, | |
| 492 | - rows_affected: 1, | |
| 493 | - }]) | |
| 494 | - .into_connection(); | |
| 495 | - | |
| 496 | - let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery) | |
| 497 | - .await | |
| 498 | - .unwrap() | |
| 499 | - .expect("row processed"); | |
| 500 | - assert_eq!(updated.status, "pending"); | |
| 501 | - assert!(updated.last_error.is_some()); | |
| 502 | - assert!(updated.next_retry_at.is_some()); | |
| 503 | - } | |
| 504 | - | |
| 505 | - #[tokio::test] | |
| 506 | - async fn drain_one_marks_dead_when_attempts_reach_max() { | |
| 507 | - let id = Uuid::new_v4(); | |
| 508 | - let mut row = pending_row(id); | |
| 509 | - row.attempts = 7; | |
| 510 | - row.max_attempts = 8; | |
| 511 | - let mut after = row.clone(); | |
| 512 | - after.status = "dead".into(); | |
| 513 | - after.attempts = 8; | |
| 514 | - after.last_error = Some("simulated transport failure".into()); | |
| 515 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 516 | - .append_query_results([vec![row]]) | |
| 517 | - .append_query_results([vec![after]]) | |
| 518 | - .append_exec_results([MockExecResult { | |
| 519 | - last_insert_id: 0, | |
| 520 | - rows_affected: 1, | |
| 521 | - }]) | |
| 522 | - .into_connection(); | |
| 523 | - | |
| 524 | - let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery) | |
| 525 | - .await | |
| 526 | - .unwrap() | |
| 527 | - .unwrap(); | |
| 528 | - assert_eq!(updated.status, "dead"); | |
| 529 | - assert!(updated.next_retry_at.is_none()); | |
| 530 | - } | |
| 531 | - | |
| 532 | - #[test] | |
| 533 | - fn next_retry_offset_grows_then_caps() { | |
| 534 | - let secs: Vec<u64> = (1..=8) | |
| 535 | - .map(|i| OutboundStatus::next_retry_offset(i).as_secs()) | |
| 536 | - .collect(); | |
| 537 | - // Strictly increasing for the first 6 entries. | |
| 538 | - assert!(secs[0] < secs[1]); | |
| 539 | - assert!(secs[1] < secs[2]); | |
| 540 | - assert!(secs[2] < secs[3]); | |
| 541 | - assert!(secs[3] < secs[4]); | |
| 542 | - assert!(secs[4] < secs[5]); | |
| 543 | - // Cap at 48h afterwards. | |
| 544 | - assert_eq!(secs[5], 48 * 3600); | |
| 545 | - assert_eq!(secs[6], 48 * 3600); | |
| 546 | - assert_eq!(secs[7], 48 * 3600); | |
| 547 | - } | |
| 548 | - | |
| 549 | - #[tokio::test] | |
| 550 | - async fn drain_one_increments_attempts() { | |
| 551 | - let id = Uuid::new_v4(); | |
| 552 | - let mut row = pending_row(id); | |
| 553 | - row.attempts = 3; | |
| 554 | - let mut after = row.clone(); | |
| 555 | - after.status = "delivered".into(); | |
| 556 | - after.attempts = 4; | |
| 557 | - after.delivered_at = Some(Utc::now()); | |
| 558 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 559 | - .append_query_results([vec![row.clone()]]) | |
| 560 | - .append_query_results([vec![after]]) | |
| 561 | - .append_exec_results([MockExecResult { | |
| 562 | - last_insert_id: 0, | |
| 563 | - rows_affected: 1, | |
| 564 | - }]) | |
| 565 | - .into_connection(); | |
| 566 | - | |
| 567 | - let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 568 | - .await | |
| 569 | - .unwrap() | |
| 570 | - .unwrap(); | |
| 571 | - assert_eq!(updated.attempts, 4); | |
| 572 | - } | |
| 573 | - | |
| 574 | - #[tokio::test] | |
| 575 | - async fn noop_delivery_returns_ok() { | |
| 576 | - let row = pending_row(Uuid::new_v4()); | |
| 577 | - assert!(NoopDelivery.deliver(&row).await.is_ok()); | |
| 578 | - } | |
| 579 | - | |
| 580 | - #[tokio::test] | |
| 581 | - async fn worker_can_be_built_and_dropped() { | |
| 582 | - // Smoke test : building the struct and dropping it before the | |
| 583 | - // loop runs must not panic. | |
| 584 | - let db: DatabaseConnection = | |
| 585 | - MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 586 | - let _worker = OutboundWorker::new(db, NoopDelivery, Duration::from_millis(10)); | |
| 587 | - // Drop happens at end of scope. | |
| 588 | - } | |
| 589 | -} |
D
crates/rustwarden-core/src/services/fediverse_signal_service.rs
+0
-1027
@@ -1,1027 +0,0 @@
| 1 | -//! Fediverse lifecycle signal handlers (RFC 0010). | |
| 2 | -//! | |
| 3 | -//! Applies incoming AP `Update{Actor}` / `Delete{Actor}` / | |
| 4 | -//! `Revoke{Membership}` signals to the local state. The HTTP-Sig | |
| 5 | -//! verification of the request that carried the signal is delegated to | |
| 6 | -//! [`SignatureVerifier`] — the implementation that ships with this RFC | |
| 7 | -//! is `AlwaysAcceptVerifier` (test-only); a follow-up RFC will plug | |
| 8 | -//! the real RFC 9421 verifier. | |
| 9 | -//! | |
| 10 | -//! Gated by `experimental-fediverse`. | |
| 11 | - | |
| 12 | -use async_trait::async_trait; | |
| 13 | -use chrono::Utc; | |
| 14 | -use sea_orm::{ | |
| 15 | - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, | |
| 16 | -}; | |
| 17 | -use uuid::Uuid; | |
| 18 | - | |
| 19 | -use crate::error::AppError; | |
| 20 | -use crate::models::{remote_identity, team_member}; | |
| 21 | - | |
| 22 | -/// Carries the actor metadata extracted from an inbound `Update{Actor}` | |
| 23 | -/// activity. | |
| 24 | -#[derive(Debug, Clone)] | |
| 25 | -pub struct UpdatedActor { | |
| 26 | - /// Canonical AP `id`. Match key against `remote_identities.ap_id`. | |
| 27 | - pub ap_id: String, | |
| 28 | - /// Optional display name (`name` field on the AP `Person`). | |
| 29 | - pub display_name: Option<String>, | |
| 30 | - /// Optional bio (`summary` field). | |
| 31 | - pub summary_html: Option<String>, | |
| 32 | - /// Optional avatar URL (`icon.url`). | |
| 33 | - pub icon_url: Option<String>, | |
| 34 | - /// Optional refreshed public key. | |
| 35 | - pub public_key_pem: Option<String>, | |
| 36 | - /// Optional raw activity body for audit / re-processing. | |
| 37 | - pub raw_json: Option<serde_json::Value>, | |
| 38 | -} | |
| 39 | - | |
| 40 | -/// Counters returned by [`FediverseSignalService::apply_delete_actor`]. | |
| 41 | -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 42 | -pub struct DeleteOutcome { | |
| 43 | - /// Number of `remote_identities` rows soft-deleted. | |
| 44 | - pub rows_disabled: u64, | |
| 45 | - /// Number of JWTs blacklisted (always `0` for the RFC 0010 slice; | |
| 46 | - /// a complementary `JwtBlacklistService::blacklist_by_remote_identity_id` | |
| 47 | - /// API will close that gap in a follow-up). | |
| 48 | - pub jwts_blacklisted: u64, | |
| 49 | -} | |
| 50 | - | |
| 51 | -/// Stateless service that applies AP lifecycle signals. | |
| 52 | -pub struct FediverseSignalService; | |
| 53 | - | |
| 54 | -impl FediverseSignalService { | |
| 55 | - /// Apply an inbound `Update{Actor}`: upsert the row keyed by | |
| 56 | - /// `ap_id`. The local fields not carried by `actor` are preserved | |
| 57 | - /// (no destructive overwrite). | |
| 58 | - pub async fn apply_update_actor( | |
| 59 | - db: &DatabaseConnection, | |
| 60 | - actor: UpdatedActor, | |
| 61 | - ) -> Result<remote_identity::Model, AppError> { | |
| 62 | - let existing = remote_identity::Entity::find() | |
| 63 | - .filter(remote_identity::Column::ApId.eq(actor.ap_id.clone())) | |
| 64 | - .one(db) | |
| 65 | - .await?; | |
| 66 | - | |
| 67 | - let now = Utc::now(); | |
| 68 | - if let Some(model) = existing { | |
| 69 | - let mut active: remote_identity::ActiveModel = model.into(); | |
| 70 | - if let Some(name) = actor.display_name { | |
| 71 | - active.display_name = Set(Some(name)); | |
| 72 | - } | |
| 73 | - if let Some(summary) = actor.summary_html { | |
| 74 | - active.summary_html = Set(Some(summary)); | |
| 75 | - } | |
| 76 | - if let Some(icon) = actor.icon_url { | |
| 77 | - active.icon_url = Set(Some(icon)); | |
| 78 | - } | |
| 79 | - if let Some(pem) = actor.public_key_pem { | |
| 80 | - active.public_key_pem = Set(pem); | |
| 81 | - } | |
| 82 | - if let Some(raw) = actor.raw_json { | |
| 83 | - active.raw_json = Set(Some(raw)); | |
| 84 | - } | |
| 85 | - active.last_fetched_at = Set(now); | |
| 86 | - Ok(active.update(db).await?) | |
| 87 | - } else { | |
| 88 | - // Minimal insert. The caller (inbox handler) is responsible | |
| 89 | - // for filling in the rest via webfinger lookup before | |
| 90 | - // calling apply_update_actor — but if the row truly is new | |
| 91 | - // we still want to persist *something* rather than 404. | |
| 92 | - let username_domain = parse_handle_from_ap_id(&actor.ap_id).unwrap_or_else(|| { | |
| 93 | - ("unknown".to_string(), "unknown".to_string()) | |
| 94 | - }); | |
| 95 | - let active = remote_identity::ActiveModel { | |
| 96 | - id: Set(Uuid::new_v4()), | |
| 97 | - ap_id: Set(actor.ap_id.clone()), | |
| 98 | - username: Set(username_domain.0), | |
| 99 | - domain: Set(username_domain.1), | |
| 100 | - display_name: Set(actor.display_name), | |
| 101 | - summary_html: Set(actor.summary_html), | |
| 102 | - inbox_url: Set(format!("{}/inbox", actor.ap_id)), | |
| 103 | - shared_inbox_url: Set(None), | |
| 104 | - public_key_pem: Set(actor.public_key_pem.unwrap_or_default()), | |
| 105 | - icon_url: Set(actor.icon_url), | |
| 106 | - actor_type: Set("Person".to_string()), | |
| 107 | - raw_json: Set(actor.raw_json), | |
| 108 | - last_fetched_at: Set(now), | |
| 109 | - disabled_at: Set(None), | |
| 110 | - created_at: Set(now), | |
| 111 | - updated_at: Set(now), | |
| 112 | - }; | |
| 113 | - Ok(active.insert(db).await?) | |
| 114 | - } | |
| 115 | - } | |
| 116 | - | |
| 117 | - /// Apply an inbound `Delete{Actor}`: flip `disabled_at` to NOW for | |
| 118 | - /// the matching `remote_identities` row. JWT blacklisting is | |
| 119 | - /// returned as `0` — see [`DeleteOutcome::jwts_blacklisted`]. | |
| 120 | - pub async fn apply_delete_actor( | |
| 121 | - db: &DatabaseConnection, | |
| 122 | - ap_id: &str, | |
| 123 | - ) -> Result<DeleteOutcome, AppError> { | |
| 124 | - let model = remote_identity::Entity::find() | |
| 125 | - .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 126 | - .one(db) | |
| 127 | - .await?; | |
| 128 | - | |
| 129 | - let Some(row) = model else { | |
| 130 | - return Ok(DeleteOutcome::default()); | |
| 131 | - }; | |
| 132 | - | |
| 133 | - if row.disabled_at.is_some() { | |
| 134 | - return Ok(DeleteOutcome { | |
| 135 | - rows_disabled: 0, | |
| 136 | - jwts_blacklisted: 0, | |
| 137 | - }); | |
| 138 | - } | |
| 139 | - | |
| 140 | - let mut active: remote_identity::ActiveModel = row.into(); | |
| 141 | - active.disabled_at = Set(Some(Utc::now())); | |
| 142 | - active.update(db).await?; | |
| 143 | - | |
| 144 | - Ok(DeleteOutcome { | |
| 145 | - rows_disabled: 1, | |
| 146 | - jwts_blacklisted: 0, | |
| 147 | - }) | |
| 148 | - } | |
| 149 | - | |
| 150 | - /// RFC 0018 extension: same as [`Self::apply_delete_actor`] but | |
| 151 | - /// also blacklists every active JWT issued for the deleted remote | |
| 152 | - /// identity. The `iterator` argument is pluggable | |
| 153 | - /// ([`crate::services::jwt_blacklist_service::ActiveTokenIterator`]) — | |
| 154 | - /// pass `&NoopActiveTokenIterator` to keep the legacy behaviour. | |
| 155 | - pub async fn apply_delete_actor_with_iterator( | |
| 156 | - db: &DatabaseConnection, | |
| 157 | - ap_id: &str, | |
| 158 | - iterator: &dyn crate::services::jwt_blacklist_service::ActiveTokenIterator, | |
| 159 | - ) -> Result<DeleteOutcome, AppError> { | |
| 160 | - let mut outcome = Self::apply_delete_actor(db, ap_id).await?; | |
| 161 | - if outcome.rows_disabled == 0 { | |
| 162 | - return Ok(outcome); | |
| 163 | - } | |
| 164 | - // Re-fetch the row to learn its `id` (the `apply_delete_actor` | |
| 165 | - // call above only returned counters). | |
| 166 | - let model = remote_identity::Entity::find() | |
| 167 | - .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 168 | - .one(db) | |
| 169 | - .await?; | |
| 170 | - if let Some(row) = model { | |
| 171 | - outcome.jwts_blacklisted = crate::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id( | |
| 172 | - db, | |
| 173 | - row.id, | |
| 174 | - iterator, | |
| 175 | - ) | |
| 176 | - .await?; | |
| 177 | - } | |
| 178 | - Ok(outcome) | |
| 179 | - } | |
| 180 | - | |
| 181 | - /// Apply an inbound `Revoke{Membership}`: drop the matching | |
| 182 | - /// `team_members` row. Returns `true` if a row was deleted. | |
| 183 | - pub async fn apply_revoke_membership( | |
| 184 | - db: &DatabaseConnection, | |
| 185 | - team_id: Uuid, | |
| 186 | - remote_identity_id: Uuid, | |
| 187 | - ) -> Result<bool, AppError> { | |
| 188 | - let result = team_member::Entity::delete_many() | |
| 189 | - .filter(team_member::Column::TeamId.eq(team_id)) | |
| 190 | - .filter( | |
| 191 | - team_member::Column::RemoteIdentityId | |
| 192 | - .eq(Some(remote_identity_id)), | |
| 193 | - ) | |
| 194 | - .exec(db) | |
| 195 | - .await?; | |
| 196 | - Ok(result.rows_affected > 0) | |
| 197 | - } | |
| 198 | -} | |
| 199 | - | |
| 200 | -/// Inbound request context passed to [`SignatureVerifier::verify`] | |
| 201 | -/// (RFC 0016). Carries every field needed by draft-cavage HTTP | |
| 202 | -/// signature verification: method + path + headers + body + the | |
| 203 | -/// actor's resolved public key. | |
| 204 | -#[derive(Debug)] | |
| 205 | -pub struct VerifyCtx<'a> { | |
| 206 | - /// AP id of the signer (extracted from the body by the caller). | |
| 207 | - pub actor_ap_id: &'a str, | |
| 208 | - /// HTTP method, lowercased (`"post"`). | |
| 209 | - pub method: &'a str, | |
| 210 | - /// Path-and-query (`"/inbox"`). | |
| 211 | - pub path: &'a str, | |
| 212 | - /// All inbound headers (case-insensitive lookup at the caller). | |
| 213 | - pub headers: &'a axum::http::HeaderMap, | |
| 214 | - /// Raw body bytes (used by the caller for digest / audit). | |
| 215 | - pub body: &'a [u8], | |
| 216 | - /// Resolved PEM public key for `actor_ap_id`. Empty when the | |
| 217 | - /// caller has not yet resolved the actor; impls may either | |
| 218 | - /// reject or fetch on their own. | |
| 219 | - pub public_key_pem: &'a str, | |
| 220 | -} | |
| 221 | - | |
| 222 | -/// HTTP-Sig verifier for inbound AP activities. | |
| 223 | -/// | |
| 224 | -/// Implementations decide whether to accept a given signature. The | |
| 225 | -/// `AlwaysAcceptVerifier` shipped here is **test/dev only**. | |
| 226 | -/// `HttpSigVerifier` from | |
| 227 | -/// [`crate::fediverse::http_signatures`] is the production | |
| 228 | -/// implementation. | |
| 229 | -#[async_trait] | |
| 230 | -pub trait SignatureVerifier: Send + Sync { | |
| 231 | - /// Verify a signature given the full request context. | |
| 232 | - async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 233 | -} | |
| 234 | - | |
| 235 | -/// Test/dev verifier that accepts any signature. | |
| 236 | -/// | |
| 237 | -/// **Never use in production.** A logged warning emphasises the | |
| 238 | -/// hazard at instantiation in production builds. | |
| 239 | -/// | |
| 240 | -/// When constructed with [`AlwaysAcceptVerifier::with_resolver`], the | |
| 241 | -/// verifier also fetches the actor over HTTP and upserts the | |
| 242 | -/// `remote_identities` row before returning `Ok(())` — this preserves | |
| 243 | -/// the contract documented by [`crate::services::fediverse_auth_service::FediverseAuthService::persist_inbound_request`] | |
| 244 | -/// (« the signature verifier ran upstream and already required this | |
| 245 | -/// row ») so downstream services don't need to special-case the debug | |
| 246 | -/// verifier. | |
| 247 | -pub struct AlwaysAcceptVerifier { | |
| 248 | - resolver: Option<(DatabaseConnection, reqwest::Client)>, | |
| 249 | -} | |
| 250 | - | |
| 251 | -impl AlwaysAcceptVerifier { | |
| 252 | - /// Verifier with no side-effects. Suitable for unit tests where the | |
| 253 | - /// caller pre-seeds `remote_identities` themselves. | |
| 254 | - pub fn new() -> Self { | |
| 255 | - Self { resolver: None } | |
| 256 | - } | |
| 257 | - | |
| 258 | - /// Verifier that, on each accepted request, fetches the actor doc | |
| 259 | - /// from `ctx.actor_ap_id` over HTTP and upserts the corresponding | |
| 260 | - /// `remote_identities` cache row. Best-effort: a fetch/parse failure | |
| 261 | - /// is logged and the request is still accepted (since this verifier | |
| 262 | - /// is « always accept » by contract). | |
| 263 | - /// | |
| 264 | - /// The internal HTTP client has [`ACTOR_FETCH_TIMEOUT`] applied. The | |
| 265 | - /// SSRF guard inside [`fetch_and_cache_remote_actor`] is **bypassed** | |
| 266 | - /// here (`allow_private_ips=true`) because this verifier is test/dev | |
| 267 | - /// only and the docker-compose testbed lives on private IPs. | |
| 268 | - pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 269 | - let http = reqwest::Client::builder() | |
| 270 | - .timeout(ACTOR_FETCH_TIMEOUT) | |
| 271 | - .build() | |
| 272 | - .expect("reqwest client builder must not fail"); | |
| 273 | - Self { | |
| 274 | - resolver: Some((db, http)), | |
| 275 | - } | |
| 276 | - } | |
| 277 | -} | |
| 278 | - | |
| 279 | -impl Default for AlwaysAcceptVerifier { | |
| 280 | - fn default() -> Self { | |
| 281 | - Self::new() | |
| 282 | - } | |
| 283 | -} | |
| 284 | - | |
| 285 | -#[async_trait] | |
| 286 | -impl SignatureVerifier for AlwaysAcceptVerifier { | |
| 287 | - async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 288 | - tracing::warn!( | |
| 289 | - actor = %ctx.actor_ap_id, | |
| 290 | - "AlwaysAcceptVerifier in use — DO NOT enable in production", | |
| 291 | - ); | |
| 292 | - if let Some((db, http)) = &self.resolver | |
| 293 | - && let Err(e) = | |
| 294 | - fetch_and_cache_remote_actor(db, http, ctx.actor_ap_id, /* allow_private_ips */ true).await | |
| 295 | - { | |
| 296 | - // « Always accept » applies to signature verification, not | |
| 297 | - // to the cache-side-effect: a failed fetch must not 401 the | |
| 298 | - // caller. Log and let downstream services decide what to do | |
| 299 | - // with an absent row. | |
| 300 | - tracing::warn!( | |
| 301 | - actor = %ctx.actor_ap_id, | |
| 302 | - error = %e, | |
| 303 | - "AlwaysAcceptVerifier: actor fetch+cache failed (continuing)", | |
| 304 | - ); | |
| 305 | - } | |
| 306 | - Ok(()) | |
| 307 | - } | |
| 308 | -} | |
| 309 | - | |
| 310 | -/// Maximum body size accepted from a remote actor fetch. Mastodon's | |
| 311 | -/// canonical Person doc is ~2 KB; 1 MiB leaves room for icons in | |
| 312 | -/// `attachment` and large `summary` fields while bounding DoS exposure. | |
| 313 | -pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024; | |
| 314 | - | |
| 315 | -/// Per-request timeout applied to every outgoing actor fetch. Caps | |
| 316 | -/// hangs from unreachable / slow remote instances without breaking the | |
| 317 | -/// inbox handler that called the verifier. | |
| 318 | -pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); | |
| 319 | - | |
| 320 | -/// Refuse to fetch URLs whose resolved IP is loopback, private, | |
| 321 | -/// link-local, ULA, or unspecified. Prevents SSRF pivots through | |
| 322 | -/// `actor_ap_id` values like `http://169.254.169.254/...` (AWS | |
| 323 | -/// metadata) or `http://10.x.x.x/admin`. | |
| 324 | -/// | |
| 325 | -/// Caller passes `allow_private_ips=true` only in test/dev contexts | |
| 326 | -/// where the topology is local (e.g. the docker-compose testbed using | |
| 327 | -/// `*.local` aliases resolving to 172.x.x.x). | |
| 328 | -async fn ssrf_guard(url: &url::Url, allow_private_ips: bool) -> Result<(), AppError> { | |
| 329 | - if allow_private_ips { | |
| 330 | - return Ok(()); | |
| 331 | - } | |
| 332 | - let host = url | |
| 333 | - .host_str() | |
| 334 | - .ok_or_else(|| AppError::Validation("URL has no host".into()))?; | |
| 335 | - let port = url.port_or_known_default().unwrap_or(80); | |
| 336 | - let addrs = tokio::net::lookup_host((host, port)) | |
| 337 | - .await | |
| 338 | - .map_err(|e| AppError::Validation(format!("DNS resolution failed for {host}: {e}")))?; | |
| 339 | - for addr in addrs { | |
| 340 | - use std::net::IpAddr; | |
| 341 | - let ip = addr.ip(); | |
| 342 | - if ip.is_loopback() || ip.is_unspecified() { | |
| 343 | - return Err(AppError::Validation(format!( | |
| 344 | - "refusing to fetch {host}: resolves to loopback/unspecified ({ip})" | |
| 345 | - ))); | |
| 346 | - } | |
| 347 | - match ip { | |
| 348 | - IpAddr::V4(v4) => { | |
| 349 | - if v4.is_private() || v4.is_link_local() { | |
| 350 | - return Err(AppError::Validation(format!( | |
| 351 | - "refusing to fetch {host}: resolves to private/link-local ({v4})" | |
| 352 | - ))); | |
| 353 | - } | |
| 354 | - } | |
| 355 | - IpAddr::V6(v6) => { | |
| 356 | - let seg0 = v6.segments()[0]; | |
| 357 | - // ULA fc00::/7, link-local fe80::/10 | |
| 358 | - if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 { | |
| 359 | - return Err(AppError::Validation(format!( | |
| 360 | - "refusing to fetch {host}: resolves to ULA/link-local ({v6})" | |
| 361 | - ))); | |
| 362 | - } | |
| 363 | - } | |
| 364 | - } | |
| 365 | - } | |
| 366 | - Ok(()) | |
| 367 | -} | |
| 368 | - | |
| 369 | -/// Fetch a remote AP `Person` actor over HTTP and upsert the matching | |
| 370 | -/// `remote_identities` row. Idempotent: a re-fetch refreshes | |
| 371 | -/// `public_key_pem`, `inbox_url`, `shared_inbox_url`, `display_name`, | |
| 372 | -/// and `last_fetched_at`. | |
| 373 | -/// | |
| 374 | -/// Hardening: | |
| 375 | -/// - 10 s per-request timeout (via the caller's `http` client). | |
| 376 | -/// - 1 MiB body cap via Content-Length sniff + post-read assertion. | |
| 377 | -/// - SSRF guard rejecting private/loopback/link-local IPs unless | |
| 378 | -/// `allow_private_ips=true` (only set by test/dev callers). | |
| 379 | -/// | |
| 380 | -/// Network/parse errors return [`AppError::Validation`]; the caller | |
| 381 | -/// chooses whether to surface them (production verifier) or swallow | |
| 382 | -/// them (test/dev verifier). | |
| 383 | -pub async fn fetch_and_cache_remote_actor( | |
| 384 | - db: &DatabaseConnection, | |
| 385 | - http: &reqwest::Client, | |
| 386 | - actor_ap_id: &str, | |
| 387 | - allow_private_ips: bool, | |
| 388 | -) -> Result<remote_identity::Model, AppError> { | |
| 389 | - let url = url::Url::parse(actor_ap_id) | |
| 390 | - .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?; | |
| 391 | - | |
| 392 | - ssrf_guard(&url, allow_private_ips).await?; | |
| 393 | - | |
| 394 | - let response = http | |
| 395 | - .get(actor_ap_id) | |
| 396 | - .header("Accept", "application/activity+json") | |
| 397 | - .send() | |
| 398 | - .await | |
| 399 | - .map_err(|e| AppError::Validation(format!("cannot fetch actor {actor_ap_id}: {e}")))?; | |
| 400 | - | |
| 401 | - if !response.status().is_success() { | |
| 402 | - return Err(AppError::Validation(format!( | |
| 403 | - "actor fetch returned {} for {actor_ap_id}", | |
| 404 | - response.status() | |
| 405 | - ))); | |
| 406 | - } | |
| 407 | - | |
| 408 | - // Belt: refuse early if the server advertises a body larger than | |
| 409 | - // our cap. Suspenders: re-check after the body is fully buffered, | |
| 410 | - // because Content-Length is optional and can lie. | |
| 411 | - if let Some(len) = response.content_length() | |
| 412 | - && len > MAX_ACTOR_BODY_BYTES | |
| 413 | - { | |
| 414 | - return Err(AppError::Validation(format!( | |
| 415 | - "actor {actor_ap_id} body too large: {len} > {MAX_ACTOR_BODY_BYTES}" | |
| 416 | - ))); | |
| 417 | - } | |
| 418 | - let bytes = response | |
| 419 | - .bytes() | |
| 420 | - .await | |
| 421 | - .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} read error: {e}")))?; | |
| 422 | - if (bytes.len() as u64) > MAX_ACTOR_BODY_BYTES { | |
| 423 | - return Err(AppError::Validation(format!( | |
| 424 | - "actor {actor_ap_id} body too large: {} > {MAX_ACTOR_BODY_BYTES}", | |
| 425 | - bytes.len() | |
| 426 | - ))); | |
| 427 | - } | |
| 428 | - | |
| 429 | - let body: serde_json::Value = serde_json::from_slice(&bytes) | |
| 430 | - .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?; | |
| 431 | - | |
| 432 | - let preferred_username = body | |
| 433 | - .get("preferredUsername") | |
| 434 | - .and_then(|v| v.as_str()) | |
| 435 | - .ok_or_else(|| { | |
| 436 | - AppError::Validation(format!("actor {actor_ap_id} missing preferredUsername")) | |
| 437 | - })? | |
| 438 | - .to_string(); | |
| 439 | - | |
| 440 | - let domain = url | |
| 441 | - .host_str() | |
| 442 | - .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} has no host")))? | |
| 443 | - .to_string(); | |
| 444 | - | |
| 445 | - let public_key_pem = body | |
| 446 | - .get("publicKey") | |
| 447 | - .and_then(|v| v.get("publicKeyPem")) | |
| 448 | - .and_then(|v| v.as_str()) | |
| 449 | - .ok_or_else(|| { | |
| 450 | - AppError::Validation(format!( | |
| 451 | - "actor {actor_ap_id} missing publicKey.publicKeyPem" | |
| 452 | - )) | |
| 453 | - })? | |
| 454 | - .to_string(); | |
| 455 | - | |
| 456 | - let inbox_url = body | |
| 457 | - .get("inbox") | |
| 458 | - .and_then(|v| v.as_str()) | |
| 459 | - .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} missing inbox")))? | |
| 460 | - .to_string(); | |
| 461 | - | |
| 462 | - let shared_inbox_url = body | |
| 463 | - .get("endpoints") | |
| 464 | - .and_then(|v| v.get("sharedInbox")) | |
| 465 | - .and_then(|v| v.as_str()) | |
| 466 | - .map(|s| s.to_string()); | |
| 467 | - | |
| 468 | - let display_name = body | |
| 469 | - .get("name") | |
| 470 | - .and_then(|v| v.as_str()) | |
| 471 | - .map(|s| s.to_string()); | |
| 472 | - | |
| 473 | - let now = Utc::now(); | |
| 474 | - let existing = remote_identity::Entity::find() | |
| 475 | - .filter(remote_identity::Column::ApId.eq(actor_ap_id)) | |
| 476 | - .one(db) | |
| 477 | - .await?; | |
| 478 | - | |
| 479 | - let model = if let Some(row) = existing { | |
| 480 | - let mut active: remote_identity::ActiveModel = row.into(); | |
| 481 | - active.public_key_pem = Set(public_key_pem); | |
| 482 | - active.inbox_url = Set(inbox_url); | |
| 483 | - active.shared_inbox_url = Set(shared_inbox_url); | |
| 484 | - active.display_name = Set(display_name); | |
| 485 | - active.raw_json = Set(Some(body)); | |
| 486 | - active.last_fetched_at = Set(now); | |
| 487 | - active.updated_at = Set(now); | |
| 488 | - active.update(db).await? | |
| 489 | - } else { | |
| 490 | - let active = remote_identity::ActiveModel { | |
| 491 | - id: Set(Uuid::new_v4()), | |
| 492 | - ap_id: Set(actor_ap_id.to_string()), | |
| 493 | - username: Set(preferred_username), | |
| 494 | - domain: Set(domain), | |
| 495 | - display_name: Set(display_name), | |
| 496 | - summary_html: Set(None), | |
| 497 | - inbox_url: Set(inbox_url), | |
| 498 | - shared_inbox_url: Set(shared_inbox_url), | |
| 499 | - public_key_pem: Set(public_key_pem), | |
| 500 | - icon_url: Set(None), | |
| 501 | - actor_type: Set("Person".to_string()), | |
| 502 | - raw_json: Set(Some(body)), | |
| 503 | - last_fetched_at: Set(now), | |
| 504 | - disabled_at: Set(None), | |
| 505 | - created_at: Set(now), | |
| 506 | - updated_at: Set(now), | |
| 507 | - }; | |
| 508 | - active.insert(db).await? | |
| 509 | - }; | |
| 510 | - Ok(model) | |
| 511 | -} | |
| 512 | - | |
| 513 | -fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> { | |
| 514 | - let url = url::Url::parse(ap_id).ok()?; | |
| 515 | - let host = url.host_str()?.to_string(); | |
| 516 | - let user = url.path_segments()?.next_back()?.to_string(); | |
| 517 | - Some((user, host)) | |
| 518 | -} | |
| 519 | - | |
| 520 | -#[cfg(test)] | |
| 521 | -mod tests { | |
| 522 | - use super::*; | |
| 523 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 524 | - | |
| 525 | - fn fresh_remote(id: Uuid, ap_id: &str) -> remote_identity::Model { | |
| 526 | - let now = Utc::now(); | |
| 527 | - remote_identity::Model { | |
| 528 | - id, | |
| 529 | - ap_id: ap_id.to_string(), | |
| 530 | - username: "alice".into(), | |
| 531 | - domain: "a.tld".into(), | |
| 532 | - display_name: Some("Alice".into()), | |
| 533 | - summary_html: None, | |
| 534 | - inbox_url: format!("{ap_id}/inbox"), | |
| 535 | - shared_inbox_url: None, | |
| 536 | - public_key_pem: "-----BEGIN PUBLIC KEY-----\n".into(), | |
| 537 | - icon_url: None, | |
| 538 | - actor_type: "Person".into(), | |
| 539 | - raw_json: None, | |
| 540 | - last_fetched_at: now, | |
| 541 | - disabled_at: None, | |
| 542 | - created_at: now, | |
| 543 | - updated_at: now, | |
| 544 | - } | |
| 545 | - } | |
| 546 | - | |
| 547 | - // ─── Pure ──────────────────────────────────────────────────────── | |
| 548 | - | |
| 549 | - #[test] | |
| 550 | - fn delete_outcome_default_is_zero() { | |
| 551 | - assert_eq!(DeleteOutcome::default().rows_disabled, 0); | |
| 552 | - assert_eq!(DeleteOutcome::default().jwts_blacklisted, 0); | |
| 553 | - } | |
| 554 | - | |
| 555 | - // ─── apply_update_actor ───────────────────────────────────────── | |
| 556 | - | |
| 557 | - #[tokio::test] | |
| 558 | - async fn apply_update_actor_upserts_when_existing() { | |
| 559 | - let id = Uuid::new_v4(); | |
| 560 | - let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 561 | - let mut after = row.clone(); | |
| 562 | - after.display_name = Some("Alice McUpdated".into()); | |
| 563 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 564 | - .append_query_results([vec![row]]) | |
| 565 | - .append_query_results([vec![after]]) | |
| 566 | - .append_exec_results([MockExecResult { | |
| 567 | - last_insert_id: 0, | |
| 568 | - rows_affected: 1, | |
| 569 | - }]) | |
| 570 | - .into_connection(); | |
| 571 | - | |
| 572 | - let updated = FediverseSignalService::apply_update_actor( | |
| 573 | - &db, | |
| 574 | - UpdatedActor { | |
| 575 | - ap_id: "https://a.tld/users/alice".into(), | |
| 576 | - display_name: Some("Alice McUpdated".into()), | |
| 577 | - summary_html: None, | |
| 578 | - icon_url: None, | |
| 579 | - public_key_pem: None, | |
| 580 | - raw_json: None, | |
| 581 | - }, | |
| 582 | - ) | |
| 583 | - .await | |
| 584 | - .unwrap(); | |
| 585 | - assert_eq!(updated.display_name.as_deref(), Some("Alice McUpdated")); | |
| 586 | - } | |
| 587 | - | |
| 588 | - #[tokio::test] | |
| 589 | - async fn apply_update_actor_inserts_when_missing() { | |
| 590 | - let id = Uuid::new_v4(); | |
| 591 | - let new_row = fresh_remote(id, "https://a.tld/users/bob"); | |
| 592 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 593 | - // 1. SELECT existing → empty | |
| 594 | - .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 595 | - // 2. INSERT returns the row | |
| 596 | - .append_query_results([vec![new_row.clone()]]) | |
| 597 | - .append_exec_results([MockExecResult { | |
| 598 | - last_insert_id: 0, | |
| 599 | - rows_affected: 1, | |
| 600 | - }]) | |
| 601 | - .into_connection(); | |
| 602 | - | |
| 603 | - let inserted = FediverseSignalService::apply_update_actor( | |
| 604 | - &db, | |
| 605 | - UpdatedActor { | |
| 606 | - ap_id: "https://a.tld/users/bob".into(), | |
| 607 | - display_name: None, | |
| 608 | - summary_html: None, | |
| 609 | - icon_url: None, | |
| 610 | - public_key_pem: None, | |
| 611 | - raw_json: None, | |
| 612 | - }, | |
| 613 | - ) | |
| 614 | - .await | |
| 615 | - .unwrap(); | |
| 616 | - assert_eq!(inserted.ap_id, new_row.ap_id); | |
| 617 | - } | |
| 618 | - | |
| 619 | - // ─── apply_delete_actor ───────────────────────────────────────── | |
| 620 | - | |
| 621 | - #[tokio::test] | |
| 622 | - async fn apply_delete_actor_sets_disabled_at_and_returns_one() { | |
| 623 | - let id = Uuid::new_v4(); | |
| 624 | - let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 625 | - let mut after = row.clone(); | |
| 626 | - after.disabled_at = Some(Utc::now()); | |
| 627 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 628 | - .append_query_results([vec![row]]) | |
| 629 | - .append_query_results([vec![after]]) | |
| 630 | - .append_exec_results([MockExecResult { | |
| 631 | - last_insert_id: 0, | |
| 632 | - rows_affected: 1, | |
| 633 | - }]) | |
| 634 | - .into_connection(); | |
| 635 | - let outcome = | |
| 636 | - FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice") | |
| 637 | - .await | |
| 638 | - .unwrap(); | |
| 639 | - assert_eq!(outcome.rows_disabled, 1); | |
| 640 | - assert_eq!(outcome.jwts_blacklisted, 0); | |
| 641 | - } | |
| 642 | - | |
| 643 | - #[tokio::test] | |
| 644 | - async fn apply_delete_actor_returns_zero_when_no_row() { | |
| 645 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 646 | - .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 647 | - .into_connection(); | |
| 648 | - let outcome = | |
| 649 | - FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/ghost") | |
| 650 | - .await | |
| 651 | - .unwrap(); | |
| 652 | - assert_eq!(outcome, DeleteOutcome::default()); | |
| 653 | - } | |
| 654 | - | |
| 655 | - #[tokio::test] | |
| 656 | - async fn apply_delete_actor_returns_zero_when_already_disabled() { | |
| 657 | - let id = Uuid::new_v4(); | |
| 658 | - let mut row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 659 | - row.disabled_at = Some(Utc::now()); | |
| 660 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 661 | - .append_query_results([vec![row]]) | |
| 662 | - .into_connection(); | |
| 663 | - let outcome = | |
| 664 | - FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice") | |
| 665 | - .await | |
| 666 | - .unwrap(); | |
| 667 | - assert_eq!(outcome.rows_disabled, 0); | |
| 668 | - } | |
| 669 | - | |
| 670 | - // ─── apply_revoke_membership ──────────────────────────────────── | |
| 671 | - | |
| 672 | - #[tokio::test] | |
| 673 | - async fn apply_revoke_membership_returns_false_when_no_row() { | |
| 674 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 675 | - .append_exec_results([MockExecResult { | |
| 676 | - last_insert_id: 0, | |
| 677 | - rows_affected: 0, | |
| 678 | - }]) | |
| 679 | - .into_connection(); | |
| 680 | - let revoked = FediverseSignalService::apply_revoke_membership( | |
| 681 | - &db, | |
| 682 | - Uuid::new_v4(), | |
| 683 | - Uuid::new_v4(), | |
| 684 | - ) | |
| 685 | - .await | |
| 686 | - .unwrap(); | |
| 687 | - assert!(!revoked); | |
| 688 | - } | |
| 689 | - | |
| 690 | - #[tokio::test] | |
| 691 | - async fn apply_revoke_membership_returns_true_when_row_dropped() { | |
| 692 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 693 | - .append_exec_results([MockExecResult { | |
| 694 | - last_insert_id: 0, | |
| 695 | - rows_affected: 1, | |
| 696 | - }]) | |
| 697 | - .into_connection(); | |
| 698 | - let revoked = FediverseSignalService::apply_revoke_membership( | |
| 699 | - &db, | |
| 700 | - Uuid::new_v4(), | |
| 701 | - Uuid::new_v4(), | |
| 702 | - ) | |
| 703 | - .await | |
| 704 | - .unwrap(); | |
| 705 | - assert!(revoked); | |
| 706 | - } | |
| 707 | - | |
| 708 | - // ─── SignatureVerifier ────────────────────────────────────────── | |
| 709 | - | |
| 710 | - fn dummy_ctx<'a>(headers: &'a axum::http::HeaderMap) -> VerifyCtx<'a> { | |
| 711 | - VerifyCtx { | |
| 712 | - actor_ap_id: "https://a.tld/users/alice", | |
| 713 | - method: "post", | |
| 714 | - path: "/inbox", | |
| 715 | - headers, | |
| 716 | - body: b"", | |
| 717 | - public_key_pem: "", | |
| 718 | - } | |
| 719 | - } | |
| 720 | - | |
| 721 | - #[tokio::test] | |
| 722 | - async fn always_accept_verifier_accepts_any_input() { | |
| 723 | - let headers = axum::http::HeaderMap::new(); | |
| 724 | - AlwaysAcceptVerifier::new() | |
| 725 | - .verify(&dummy_ctx(&headers)) | |
| 726 | - .await | |
| 727 | - .expect("AlwaysAcceptVerifier never rejects"); | |
| 728 | - } | |
| 729 | - | |
| 730 | - #[tokio::test] | |
| 731 | - async fn signature_verifier_trait_object_works() { | |
| 732 | - let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier::new()); | |
| 733 | - let headers = axum::http::HeaderMap::new(); | |
| 734 | - verifier | |
| 735 | - .verify(&dummy_ctx(&headers)) | |
| 736 | - .await | |
| 737 | - .expect("trait object dispatches"); | |
| 738 | - } | |
| 739 | - | |
| 740 | - // ─── RFC 0018 — apply_delete_actor_with_iterator ──────────────── | |
| 741 | - | |
| 742 | - #[tokio::test] | |
| 743 | - async fn apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero() { | |
| 744 | - use crate::services::jwt_blacklist_service::NoopActiveTokenIterator; | |
| 745 | - let id = Uuid::new_v4(); | |
| 746 | - let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 747 | - let mut after = row.clone(); | |
| 748 | - after.disabled_at = Some(Utc::now()); | |
| 749 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 750 | - // SELECT for apply_delete_actor | |
| 751 | - .append_query_results([vec![row.clone()]]) | |
| 752 | - // SELECT issued by .update() inside apply_delete_actor | |
| 753 | - .append_query_results([vec![after.clone()]]) | |
| 754 | - // SELECT in apply_delete_actor_with_iterator (re-fetch) | |
| 755 | - .append_query_results([vec![after]]) | |
| 756 | - .append_exec_results([MockExecResult { | |
| 757 | - last_insert_id: 0, | |
| 758 | - rows_affected: 1, | |
| 759 | - }]) | |
| 760 | - .into_connection(); | |
| 761 | - let outcome = FediverseSignalService::apply_delete_actor_with_iterator( | |
| 762 | - &db, | |
| 763 | - "https://a.tld/users/alice", | |
| 764 | - &NoopActiveTokenIterator, | |
| 765 | - ) | |
| 766 | - .await | |
| 767 | - .unwrap(); | |
| 768 | - assert_eq!(outcome.rows_disabled, 1); | |
| 769 | - assert_eq!(outcome.jwts_blacklisted, 0); | |
| 770 | - } | |
| 771 | - | |
| 772 | - #[tokio::test] | |
| 773 | - async fn noop_active_token_iterator_returns_empty_list() { | |
| 774 | - use crate::services::jwt_blacklist_service::{ | |
| 775 | - ActiveTokenIterator, NoopActiveTokenIterator, | |
| 776 | - }; | |
| 777 | - let v = NoopActiveTokenIterator | |
| 778 | - .active_tokens_for_remote(Uuid::new_v4()) | |
| 779 | - .await | |
| 780 | - .unwrap(); | |
| 781 | - assert!(v.is_empty()); | |
| 782 | - } | |
| 783 | - | |
| 784 | - // ─── fetch_and_cache_remote_actor (F2 hardening) ──────────────── | |
| 785 | - | |
| 786 | - /// Build a syntactically valid AP Person doc with the given overrides. | |
| 787 | - /// `extra` is merged at the top level — pass `null` for « no extra ». | |
| 788 | - fn ap_person_json( | |
| 789 | - actor_url: &str, | |
| 790 | - username: &str, | |
| 791 | - with_public_key: bool, | |
| 792 | - with_inbox: bool, | |
| 793 | - ) -> serde_json::Value { | |
| 794 | - let mut doc = serde_json::json!({ | |
| 795 | - "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 796 | - "type": "Person", | |
| 797 | - "id": actor_url, | |
| 798 | - "preferredUsername": username, | |
| 799 | - "name": username, | |
| 800 | - }); | |
| 801 | - if with_public_key { | |
| 802 | - doc["publicKey"] = serde_json::json!({ | |
| 803 | - "id": format!("{actor_url}#main-key"), | |
| 804 | - "owner": actor_url, | |
| 805 | - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----\n", | |
| 806 | - }); | |
| 807 | - } | |
| 808 | - if with_inbox { | |
| 809 | - doc["inbox"] = serde_json::json!(format!("{actor_url}/inbox")); | |
| 810 | - } | |
| 811 | - doc | |
| 812 | - } | |
| 813 | - | |
| 814 | - fn testbed_http_client() -> reqwest::Client { | |
| 815 | - reqwest::Client::builder() | |
| 816 | - .timeout(std::time::Duration::from_secs(2)) | |
| 817 | - .build() | |
| 818 | - .unwrap() | |
| 819 | - } | |
| 820 | - | |
| 821 | - #[tokio::test] | |
| 822 | - async fn fetch_inserts_new_remote_identity_on_first_sight() { | |
| 823 | - use wiremock::matchers::{method, path}; | |
| 824 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 825 | - | |
| 826 | - let server = MockServer::start().await; | |
| 827 | - let actor_url = format!("{}/users/alice", server.uri()); | |
| 828 | - Mock::given(method("GET")) | |
| 829 | - .and(path("/users/alice")) | |
| 830 | - .respond_with( | |
| 831 | - ResponseTemplate::new(200) | |
| 832 | - .set_body_json(ap_person_json(&actor_url, "alice", true, true)), | |
| 833 | - ) | |
| 834 | - .mount(&server) | |
| 835 | - .await; | |
| 836 | - | |
| 837 | - let inserted = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 838 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 839 | - .append_query_results([Vec::<remote_identity::Model>::new()]) // SELECT empty | |
| 840 | - .append_query_results([vec![inserted.clone()]]) // INSERT RETURNING | |
| 841 | - .append_exec_results([MockExecResult { | |
| 842 | - last_insert_id: 0, | |
| 843 | - rows_affected: 1, | |
| 844 | - }]) | |
| 845 | - .into_connection(); | |
| 846 | - | |
| 847 | - let model = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 848 | - .await | |
| 849 | - .expect("happy path INSERT must succeed"); | |
| 850 | - assert_eq!(model.ap_id, inserted.ap_id); | |
| 851 | - } | |
| 852 | - | |
| 853 | - #[tokio::test] | |
| 854 | - async fn fetch_updates_existing_remote_identity_on_refetch() { | |
| 855 | - use wiremock::matchers::{method, path}; | |
| 856 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 857 | - | |
| 858 | - let server = MockServer::start().await; | |
| 859 | - let actor_url = format!("{}/users/bob", server.uri()); | |
| 860 | - Mock::given(method("GET")) | |
| 861 | - .and(path("/users/bob")) | |
| 862 | - .respond_with( | |
| 863 | - ResponseTemplate::new(200) | |
| 864 | - .set_body_json(ap_person_json(&actor_url, "bob", true, true)), | |
| 865 | - ) | |
| 866 | - .mount(&server) | |
| 867 | - .await; | |
| 868 | - | |
| 869 | - let existing = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 870 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 871 | - .append_query_results([vec![existing.clone()]]) // SELECT hit | |
| 872 | - .append_query_results([vec![existing.clone()]]) // UPDATE RETURNING | |
| 873 | - .append_exec_results([MockExecResult { | |
| 874 | - last_insert_id: 0, | |
| 875 | - rows_affected: 1, | |
| 876 | - }]) | |
| 877 | - .into_connection(); | |
| 878 | - | |
| 879 | - fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 880 | - .await | |
| 881 | - .expect("happy path UPDATE must succeed"); | |
| 882 | - } | |
| 883 | - | |
| 884 | - #[tokio::test] | |
| 885 | - async fn fetch_returns_error_on_http_404() { | |
| 886 | - use wiremock::matchers::{method, path}; | |
| 887 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 888 | - | |
| 889 | - let server = MockServer::start().await; | |
| 890 | - let actor_url = format!("{}/users/ghost", server.uri()); | |
| 891 | - Mock::given(method("GET")) | |
| 892 | - .and(path("/users/ghost")) | |
| 893 | - .respond_with(ResponseTemplate::new(404)) | |
| 894 | - .mount(&server) | |
| 895 | - .await; | |
| 896 | - | |
| 897 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 898 | - let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 899 | - .await | |
| 900 | - .expect_err("404 must be surfaced as Validation"); | |
| 901 | - assert!(format!("{err}").contains("404"), "got: {err}"); | |
| 902 | - } | |
| 903 | - | |
| 904 | - #[tokio::test] | |
| 905 | - async fn fetch_returns_error_on_malformed_json() { | |
| 906 | - use wiremock::matchers::{method, path}; | |
| 907 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 908 | - | |
| 909 | - let server = MockServer::start().await; | |
| 910 | - let actor_url = format!("{}/users/junk", server.uri()); | |
| 911 | - Mock::given(method("GET")) | |
| 912 | - .and(path("/users/junk")) | |
| 913 | - .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all")) | |
| 914 | - .mount(&server) | |
| 915 | - .await; | |
| 916 | - | |
| 917 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 918 | - let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 919 | - .await | |
| 920 | - .expect_err("malformed JSON must be rejected"); | |
| 921 | - assert!(format!("{err}").contains("not valid JSON"), "got: {err}"); | |
| 922 | - } | |
| 923 | - | |
| 924 | - #[tokio::test] | |
| 925 | - async fn fetch_returns_error_when_public_key_missing() { | |
| 926 | - use wiremock::matchers::{method, path}; | |
| 927 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 928 | - | |
| 929 | - let server = MockServer::start().await; | |
| 930 | - let actor_url = format!("{}/users/nokey", server.uri()); | |
| 931 | - Mock::given(method("GET")) | |
| 932 | - .and(path("/users/nokey")) | |
| 933 | - .respond_with( | |
| 934 | - ResponseTemplate::new(200) | |
| 935 | - .set_body_json(ap_person_json(&actor_url, "nokey", false, true)), | |
| 936 | - ) | |
| 937 | - .mount(&server) | |
| 938 | - .await; | |
| 939 | - | |
| 940 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 941 | - let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 942 | - .await | |
| 943 | - .expect_err("missing publicKey must be rejected"); | |
| 944 | - assert!(format!("{err}").contains("publicKey"), "got: {err}"); | |
| 945 | - } | |
| 946 | - | |
| 947 | - #[tokio::test] | |
| 948 | - async fn fetch_returns_error_when_inbox_missing() { | |
| 949 | - use wiremock::matchers::{method, path}; | |
| 950 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 951 | - | |
| 952 | - let server = MockServer::start().await; | |
| 953 | - let actor_url = format!("{}/users/noinbox", server.uri()); | |
| 954 | - Mock::given(method("GET")) | |
| 955 | - .and(path("/users/noinbox")) | |
| 956 | - .respond_with( | |
| 957 | - ResponseTemplate::new(200) | |
| 958 | - .set_body_json(ap_person_json(&actor_url, "noinbox", true, false)), | |
| 959 | - ) | |
| 960 | - .mount(&server) | |
| 961 | - .await; | |
| 962 | - | |
| 963 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 964 | - let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 965 | - .await | |
| 966 | - .expect_err("missing inbox must be rejected"); | |
| 967 | - assert!(format!("{err}").contains("inbox"), "got: {err}"); | |
| 968 | - } | |
| 969 | - | |
| 970 | - #[tokio::test] | |
| 971 | - async fn fetch_returns_error_on_invalid_url() { | |
| 972 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 973 | - let err = | |
| 974 | - fetch_and_cache_remote_actor(&db, &testbed_http_client(), "not a valid url", true) | |
| 975 | - .await | |
| 976 | - .expect_err("malformed URL must be rejected before HTTP"); | |
| 977 | - assert!(format!("{err}").contains("not a valid URL"), "got: {err}"); | |
| 978 | - } | |
| 979 | - | |
| 980 | - #[tokio::test] | |
| 981 | - async fn fetch_returns_error_when_body_exceeds_cap() { | |
| 982 | - use wiremock::matchers::{method, path}; | |
| 983 | - use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 984 | - | |
| 985 | - let server = MockServer::start().await; | |
| 986 | - let actor_url = format!("{}/users/huge", server.uri()); | |
| 987 | - // Send a payload just over our cap. wiremock sets a truthful | |
| 988 | - // Content-Length, so the pre-flight check trips before we | |
| 989 | - // allocate the full body in our process. | |
| 990 | - let payload = vec![b'x'; (MAX_ACTOR_BODY_BYTES as usize) + 1]; | |
| 991 | - Mock::given(method("GET")) | |
| 992 | - .and(path("/users/huge")) | |
| 993 | - .respond_with(ResponseTemplate::new(200).set_body_bytes(payload)) | |
| 994 | - .mount(&server) | |
| 995 | - .await; | |
| 996 | - | |
| 997 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 998 | - let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 999 | - .await | |
| 1000 | - .expect_err("oversized body must be rejected"); | |
| 1001 | - assert!(format!("{err}").contains("too large"), "got: {err}"); | |
| 1002 | - } | |
| 1003 | - | |
| 1004 | - #[tokio::test] | |
| 1005 | - async fn ssrf_guard_rejects_loopback_when_strict() { | |
| 1006 | - // Use a known-loopback URL (wiremock binds 127.0.0.1, so the | |
| 1007 | - // server starts but the guard must reject before HTTP fires). | |
| 1008 | - use wiremock::MockServer; | |
| 1009 | - let server = MockServer::start().await; | |
| 1010 | - let actor_url = format!("{}/users/anything", server.uri()); | |
| 1011 | - | |
| 1012 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 1013 | - let err = fetch_and_cache_remote_actor( | |
| 1014 | - &db, | |
| 1015 | - &testbed_http_client(), | |
| 1016 | - &actor_url, | |
| 1017 | - /* allow_private_ips */ false, | |
| 1018 | - ) | |
| 1019 | - .await | |
| 1020 | - .expect_err("loopback must be refused when allow_private_ips=false"); | |
| 1021 | - let msg = format!("{err}"); | |
| 1022 | - assert!( | |
| 1023 | - msg.contains("loopback") || msg.contains("private") || msg.contains("link-local"), | |
| 1024 | - "expected SSRF guard rejection, got: {err}" | |
| 1025 | - ); | |
| 1026 | - } | |
| 1027 | -} |
D
crates/rustwarden-core/src/services/fediverse_trust_service.rs
+0
-449
@@ -1,449 +0,0 @@
| 1 | -//! Federation trust model service (RFC 0004). | |
| 2 | -//! | |
| 3 | -//! Implements the runtime-configurable trust check applied **before** any | |
| 4 | -//! signature verification or fetch towards a remote Fediverse domain. The | |
| 5 | -//! mode (allowlist / open + blocklist), the global enabled flag, and the | |
| 6 | -//! interop scope are stored in `app_settings`; the lists themselves live | |
| 7 | -//! in `fediverse_trusted_domains` and `fediverse_blocked_domains`. | |
| 8 | -//! | |
| 9 | -//! Gated by `experimental-fediverse`. | |
| 10 | - | |
| 11 | -use chrono::Utc; | |
| 12 | -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; | |
| 13 | -use uuid::Uuid; | |
| 14 | - | |
| 15 | -use crate::error::AppError; | |
| 16 | -use crate::models::{fediverse_blocked_domain, fediverse_trusted_domain}; | |
| 17 | -use crate::services::app_settings_service::AppSettingsService; | |
| 18 | - | |
| 19 | -/// Available federation trust modes (`app_settings.fediverse_trust_mode`). | |
| 20 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 21 | -pub enum TrustMode { | |
| 22 | - /// Zero-trust default: only domains explicitly added to the allowlist | |
| 23 | - /// are accepted. | |
| 24 | - Allowlist, | |
| 25 | - /// Mastodon-style: any domain is accepted unless present in the | |
| 26 | - /// blocklist. | |
| 27 | - OpenWithBlocklist, | |
| 28 | -} | |
| 29 | - | |
| 30 | -impl TrustMode { | |
| 31 | - /// Parse the wire form stored in `app_settings`. | |
| 32 | - pub fn parse(raw: &str) -> Self { | |
| 33 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 34 | - "open_with_blocklist" | "open" | "blocklist" => Self::OpenWithBlocklist, | |
| 35 | - _ => Self::Allowlist, | |
| 36 | - } | |
| 37 | - } | |
| 38 | -} | |
| 39 | - | |
| 40 | -/// Available interop scopes (`app_settings.fediverse_interop_scope`). | |
| 41 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 42 | -pub enum InteropScope { | |
| 43 | - /// Authentication and invitations restricted to Rustwarden instances. | |
| 44 | - RustwardenOnly, | |
| 45 | - /// Auth restricted to Rustwarden, but profile reads accepted from any | |
| 46 | - /// AP-compatible instance (Mastodon discovery use-case). | |
| 47 | - RustwardenPlusUniversalRead, | |
| 48 | - /// Anything goes (advanced). | |
| 49 | - Open, | |
| 50 | -} | |
| 51 | - | |
| 52 | -impl InteropScope { | |
| 53 | - /// Parse the wire form stored in `app_settings`. | |
| 54 | - pub fn parse(raw: &str) -> Self { | |
| 55 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 56 | - "rustwarden_plus_universal_read" | "universal_read" => { | |
| 57 | - Self::RustwardenPlusUniversalRead | |
| 58 | - } | |
| 59 | - "open" => Self::Open, | |
| 60 | - _ => Self::RustwardenOnly, | |
| 61 | - } | |
| 62 | - } | |
| 63 | -} | |
| 64 | - | |
| 65 | -/// Severity level of a [`fediverse_blocked_domain`] entry. | |
| 66 | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 67 | -pub enum BlockSeverity { | |
| 68 | - /// Reject all activities and auth requests from the domain. | |
| 69 | - Suspend, | |
| 70 | - /// Accept but flag activities silently. | |
| 71 | - Silence, | |
| 72 | - /// Reject auth specifically (allow read-only profile lookups). | |
| 73 | - RejectAuth, | |
| 74 | -} | |
| 75 | - | |
| 76 | -impl BlockSeverity { | |
| 77 | - /// Parse the wire form. Falls back to [`BlockSeverity::Suspend`] for | |
| 78 | - /// unknown values (conservative default). | |
| 79 | - pub fn parse(raw: &str) -> Self { | |
| 80 | - match raw.trim().to_ascii_lowercase().as_str() { | |
| 81 | - "silence" => Self::Silence, | |
| 82 | - "reject_auth" => Self::RejectAuth, | |
| 83 | - _ => Self::Suspend, | |
| 84 | - } | |
| 85 | - } | |
| 86 | -} | |
| 87 | - | |
| 88 | -/// Normalise a domain to its canonical comparison form: trim whitespace, | |
| 89 | -/// lowercase, strip a single leading `www.` segment. | |
| 90 | -pub fn normalize_domain(raw: &str) -> String { | |
| 91 | - let trimmed = raw.trim().to_ascii_lowercase(); | |
| 92 | - trimmed | |
| 93 | - .strip_prefix("www.") | |
| 94 | - .map(|s| s.to_string()) | |
| 95 | - .unwrap_or(trimmed) | |
| 96 | -} | |
| 97 | - | |
| 98 | -/// Service exposing the central `is_domain_allowed` gate plus admin CRUD | |
| 99 | -/// over the trust/block lists. | |
| 100 | -#[derive(Clone)] | |
| 101 | -pub struct FediverseTrustService { | |
| 102 | - db: DatabaseConnection, | |
| 103 | -} | |
| 104 | - | |
| 105 | -impl FediverseTrustService { | |
| 106 | - /// Build a service bound to the given DB connection. | |
| 107 | - pub fn new(db: DatabaseConnection) -> Self { | |
| 108 | - Self { db } | |
| 109 | - } | |
| 110 | - | |
| 111 | - /// Read `app_settings.fediverse_enabled` (default `false`). | |
| 112 | - pub async fn is_enabled(&self) -> Result<bool, AppError> { | |
| 113 | - AppSettingsService::get_bool(&self.db, "fediverse_enabled", false).await | |
| 114 | - } | |
| 115 | - | |
| 116 | - /// Read `app_settings.fediverse_trust_mode` (default | |
| 117 | - /// [`TrustMode::Allowlist`]). | |
| 118 | - pub async fn trust_mode(&self) -> Result<TrustMode, AppError> { | |
| 119 | - let raw = AppSettingsService::get_setting(&self.db, "fediverse_trust_mode").await?; | |
| 120 | - Ok(raw.map(|m| TrustMode::parse(&m.value)).unwrap_or(TrustMode::Allowlist)) | |
| 121 | - } | |
| 122 | - | |
| 123 | - /// Read `app_settings.fediverse_interop_scope` (default | |
| 124 | - /// [`InteropScope::RustwardenOnly`]). | |
| 125 | - pub async fn interop_scope(&self) -> Result<InteropScope, AppError> { | |
| 126 | - let raw = AppSettingsService::get_setting(&self.db, "fediverse_interop_scope").await?; | |
| 127 | - Ok(raw | |
| 128 | - .map(|m| InteropScope::parse(&m.value)) | |
| 129 | - .unwrap_or(InteropScope::RustwardenOnly)) | |
| 130 | - } | |
| 131 | - | |
| 132 | - /// Central trust check applied **before** any signature verification or | |
| 133 | - /// fetch towards a remote domain. | |
| 134 | - /// | |
| 135 | - /// Returns `false` (deny) when: | |
| 136 | - /// - `app_settings.fediverse_enabled = false` | |
| 137 | - /// - mode = [`TrustMode::Allowlist`] and `domain` is **not** in | |
| 138 | - /// `fediverse_trusted_domains` | |
| 139 | - /// - mode = [`TrustMode::OpenWithBlocklist`] and `domain` is in | |
| 140 | - /// `fediverse_blocked_domains` | |
| 141 | - /// | |
| 142 | - /// `domain` is normalised via [`normalize_domain`] before lookup. | |
| 143 | - pub async fn is_domain_allowed(&self, domain: &str) -> Result<bool, AppError> { | |
| 144 | - if !self.is_enabled().await? { | |
| 145 | - return Ok(false); | |
| 146 | - } | |
| 147 | - let normalized = normalize_domain(domain); | |
| 148 | - match self.trust_mode().await? { | |
| 149 | - TrustMode::Allowlist => self.is_in_trusted(&normalized).await, | |
| 150 | - TrustMode::OpenWithBlocklist => Ok(!self.is_in_blocked(&normalized).await?), | |
| 151 | - } | |
| 152 | - } | |
| 153 | - | |
| 154 | - /// F8 / RFC 0027 enforcement helper. Returns `Ok(())` when the | |
| 155 | - /// origin domain of an inbound activity is allowed; otherwise | |
| 156 | - /// returns [`AppError::Validation`] with a message safe to surface | |
| 157 | - /// (no leakage of internal state beyond the domain name). | |
| 158 | - /// | |
| 159 | - /// Intended call sites: top of every protocolar inbox / route that | |
| 160 | - /// accepts data from outside. Cheap (2 SELECTs at most) — caches | |
| 161 | - /// can be added later if needed. | |
| 162 | - pub async fn assert_domain_allowed(&self, domain: &str) -> Result<(), AppError> { | |
| 163 | - if self.is_domain_allowed(domain).await? { | |
| 164 | - Ok(()) | |
| 165 | - } else { | |
| 166 | - Err(AppError::Validation(format!( | |
| 167 | - "domain `{}` is not allowed by this server's trust policy", | |
| 168 | - normalize_domain(domain) | |
| 169 | - ))) | |
| 170 | - } | |
| 171 | - } | |
| 172 | - | |
| 173 | - async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> { | |
| 174 | - Ok(fediverse_trusted_domain::Entity::find() | |
| 175 | - .filter(fediverse_trusted_domain::Column::Domain.eq(domain)) | |
| 176 | - .one(&self.db) | |
| 177 | - .await? | |
| 178 | - .is_some()) | |
| 179 | - } | |
| 180 | - | |
| 181 | - async fn is_in_blocked(&self, domain: &str) -> Result<bool, AppError> { | |
| 182 | - Ok(fediverse_blocked_domain::Entity::find() | |
| 183 | - .filter(fediverse_blocked_domain::Column::Domain.eq(domain)) | |
| 184 | - .one(&self.db) | |
| 185 | - .await? | |
| 186 | - .is_some()) | |
| 187 | - } | |
| 188 | - | |
| 189 | - /// Insert a new entry in the trusted-domains allowlist. | |
| 190 | - pub async fn add_trusted_domain( | |
| 191 | - &self, | |
| 192 | - domain: &str, | |
| 193 | - note: Option<&str>, | |
| 194 | - added_by: Option<Uuid>, | |
| 195 | - ) -> Result<fediverse_trusted_domain::Model, AppError> { | |
| 196 | - let now = Utc::now(); | |
| 197 | - let model = fediverse_trusted_domain::ActiveModel { | |
| 198 | - id: Set(Uuid::new_v4()), | |
| 199 | - domain: Set(normalize_domain(domain)), | |
| 200 | - note: Set(note.map(str::to_string)), | |
| 201 | - added_by_user_id: Set(added_by), | |
| 202 | - created_at: Set(now), | |
| 203 | - }; | |
| 204 | - Ok(model.insert(&self.db).await?) | |
| 205 | - } | |
| 206 | - | |
| 207 | - /// Remove an entry from the trusted-domains allowlist. | |
| 208 | - /// Returns `true` if a row was deleted, `false` if no match was found. | |
| 209 | - pub async fn remove_trusted_domain(&self, domain: &str) -> Result<bool, AppError> { | |
| 210 | - let normalized = normalize_domain(domain); | |
| 211 | - let result = fediverse_trusted_domain::Entity::delete_many() | |
| 212 | - .filter(fediverse_trusted_domain::Column::Domain.eq(normalized)) | |
| 213 | - .exec(&self.db) | |
| 214 | - .await?; | |
| 215 | - Ok(result.rows_affected > 0) | |
| 216 | - } | |
| 217 | - | |
| 218 | - /// List all entries in the trusted-domains allowlist. | |
| 219 | - pub async fn list_trusted_domains( | |
| 220 | - &self, | |
| 221 | - ) -> Result<Vec<fediverse_trusted_domain::Model>, AppError> { | |
| 222 | - Ok(fediverse_trusted_domain::Entity::find() | |
| 223 | - .all(&self.db) | |
| 224 | - .await?) | |
| 225 | - } | |
| 226 | - | |
| 227 | - /// Insert a new entry in the blocked-domains blocklist. | |
| 228 | - pub async fn add_blocked_domain( | |
| 229 | - &self, | |
| 230 | - domain: &str, | |
| 231 | - severity: BlockSeverity, | |
| 232 | - reason: Option<&str>, | |
| 233 | - added_by: Option<Uuid>, | |
| 234 | - ) -> Result<fediverse_blocked_domain::Model, AppError> { | |
| 235 | - let now = Utc::now(); | |
| 236 | - let severity_str = match severity { | |
| 237 | - BlockSeverity::Suspend => "suspend", | |
| 238 | - BlockSeverity::Silence => "silence", | |
| 239 | - BlockSeverity::RejectAuth => "reject_auth", | |
| 240 | - }; | |
| 241 | - let model = fediverse_blocked_domain::ActiveModel { | |
| 242 | - id: Set(Uuid::new_v4()), | |
| 243 | - domain: Set(normalize_domain(domain)), | |
| 244 | - severity: Set(severity_str.to_string()), | |
| 245 | - reason: Set(reason.map(str::to_string)), | |
| 246 | - added_by_user_id: Set(added_by), | |
| 247 | - created_at: Set(now), | |
| 248 | - }; | |
| 249 | - Ok(model.insert(&self.db).await?) | |
| 250 | - } | |
| 251 | - | |
| 252 | - /// Remove an entry from the blocked-domains blocklist. | |
| 253 | - /// Returns `true` if a row was deleted. | |
| 254 | - pub async fn remove_blocked_domain(&self, domain: &str) -> Result<bool, AppError> { | |
| 255 | - let normalized = normalize_domain(domain); | |
| 256 | - let result = fediverse_blocked_domain::Entity::delete_many() | |
| 257 | - .filter(fediverse_blocked_domain::Column::Domain.eq(normalized)) | |
| 258 | - .exec(&self.db) | |
| 259 | - .await?; | |
| 260 | - Ok(result.rows_affected > 0) | |
| 261 | - } | |
| 262 | - | |
| 263 | - /// List all entries in the blocked-domains blocklist. | |
| 264 | - pub async fn list_blocked_domains( | |
| 265 | - &self, | |
| 266 | - ) -> Result<Vec<fediverse_blocked_domain::Model>, AppError> { | |
| 267 | - Ok(fediverse_blocked_domain::Entity::find() | |
| 268 | - .all(&self.db) | |
| 269 | - .await?) | |
| 270 | - } | |
| 271 | -} | |
| 272 | - | |
| 273 | -#[cfg(test)] | |
| 274 | -mod tests { | |
| 275 | - use super::*; | |
| 276 | - use crate::models::app_settings; | |
| 277 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 278 | - | |
| 279 | - fn enabled_setting(value: &str) -> app_settings::Model { | |
| 280 | - let now = Utc::now(); | |
| 281 | - app_settings::Model { | |
| 282 | - id: Uuid::new_v4(), | |
| 283 | - key: "fediverse_enabled".to_string(), | |
| 284 | - value: value.to_string(), | |
| 285 | - description: None, | |
| 286 | - updated_by: None, | |
| 287 | - updated_at: now, | |
| 288 | - } | |
| 289 | - } | |
| 290 | - | |
| 291 | - fn mode_setting(value: &str) -> app_settings::Model { | |
| 292 | - let now = Utc::now(); | |
| 293 | - app_settings::Model { | |
| 294 | - id: Uuid::new_v4(), | |
| 295 | - key: "fediverse_trust_mode".to_string(), | |
| 296 | - value: value.to_string(), | |
| 297 | - description: None, | |
| 298 | - updated_by: None, | |
| 299 | - updated_at: now, | |
| 300 | - } | |
| 301 | - } | |
| 302 | - | |
| 303 | - fn trusted_row(domain: &str) -> fediverse_trusted_domain::Model { | |
| 304 | - let now = Utc::now(); | |
| 305 | - fediverse_trusted_domain::Model { | |
| 306 | - id: Uuid::new_v4(), | |
| 307 | - domain: domain.to_string(), | |
| 308 | - note: None, | |
| 309 | - added_by_user_id: None, | |
| 310 | - created_at: now, | |
| 311 | - } | |
| 312 | - } | |
| 313 | - | |
| 314 | - fn blocked_row(domain: &str) -> fediverse_blocked_domain::Model { | |
| 315 | - let now = Utc::now(); | |
| 316 | - fediverse_blocked_domain::Model { | |
| 317 | - id: Uuid::new_v4(), | |
| 318 | - domain: domain.to_string(), | |
| 319 | - severity: "suspend".to_string(), | |
| 320 | - reason: None, | |
| 321 | - added_by_user_id: None, | |
| 322 | - created_at: now, | |
| 323 | - } | |
| 324 | - } | |
| 325 | - | |
| 326 | - // ─── Pure helpers ──────────────────────────────────────────────── | |
| 327 | - | |
| 328 | - #[test] | |
| 329 | - fn trust_mode_parses_known_values() { | |
| 330 | - assert_eq!(TrustMode::parse("allowlist"), TrustMode::Allowlist); | |
| 331 | - assert_eq!( | |
| 332 | - TrustMode::parse("open_with_blocklist"), | |
| 333 | - TrustMode::OpenWithBlocklist | |
| 334 | - ); | |
| 335 | - assert_eq!(TrustMode::parse("open"), TrustMode::OpenWithBlocklist); | |
| 336 | - } | |
| 337 | - | |
| 338 | - #[test] | |
| 339 | - fn trust_mode_falls_back_to_allowlist_for_unknown() { | |
| 340 | - assert_eq!(TrustMode::parse("garbage"), TrustMode::Allowlist); | |
| 341 | - assert_eq!(TrustMode::parse(""), TrustMode::Allowlist); | |
| 342 | - } | |
| 343 | - | |
| 344 | - #[test] | |
| 345 | - fn interop_scope_parses_known_values() { | |
| 346 | - assert_eq!( | |
| 347 | - InteropScope::parse("rustwarden_only"), | |
| 348 | - InteropScope::RustwardenOnly | |
| 349 | - ); | |
| 350 | - assert_eq!( | |
| 351 | - InteropScope::parse("rustwarden_plus_universal_read"), | |
| 352 | - InteropScope::RustwardenPlusUniversalRead | |
| 353 | - ); | |
| 354 | - assert_eq!(InteropScope::parse("open"), InteropScope::Open); | |
| 355 | - } | |
| 356 | - | |
| 357 | - #[test] | |
| 358 | - fn block_severity_parses_known_values() { | |
| 359 | - assert_eq!(BlockSeverity::parse("suspend"), BlockSeverity::Suspend); | |
| 360 | - assert_eq!(BlockSeverity::parse("silence"), BlockSeverity::Silence); | |
| 361 | - assert_eq!(BlockSeverity::parse("reject_auth"), BlockSeverity::RejectAuth); | |
| 362 | - } | |
| 363 | - | |
| 364 | - #[test] | |
| 365 | - fn block_severity_falls_back_to_suspend_for_unknown() { | |
| 366 | - assert_eq!(BlockSeverity::parse("nope"), BlockSeverity::Suspend); | |
| 367 | - } | |
| 368 | - | |
| 369 | - #[test] | |
| 370 | - fn normalize_domain_lowercases_and_strips_www() { | |
| 371 | - assert_eq!(normalize_domain("Example.ORG"), "example.org"); | |
| 372 | - assert_eq!(normalize_domain("www.example.org"), "example.org"); | |
| 373 | - assert_eq!(normalize_domain(" www.Mastodon.SOCIAL "), "mastodon.social"); | |
| 374 | - assert_eq!(normalize_domain("example.org"), "example.org"); | |
| 375 | - } | |
| 376 | - | |
| 377 | - // ─── DB-mocked tests ──────────────────────────────────────────── | |
| 378 | - | |
| 379 | - #[tokio::test] | |
| 380 | - async fn is_domain_allowed_returns_false_when_disabled() { | |
| 381 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 382 | - .append_query_results([vec![enabled_setting("false")]]) | |
| 383 | - .into_connection(); | |
| 384 | - let svc = FediverseTrustService::new(db); | |
| 385 | - assert!(!svc.is_domain_allowed("trusted.tld").await.unwrap()); | |
| 386 | - } | |
| 387 | - | |
| 388 | - #[tokio::test] | |
| 389 | - async fn is_domain_allowed_returns_true_when_in_allowlist() { | |
| 390 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 391 | - .append_query_results([vec![enabled_setting("true")]]) | |
| 392 | - .append_query_results([vec![mode_setting("allowlist")]]) | |
| 393 | - .append_query_results([vec![trusted_row("partner.tld")]]) | |
| 394 | - .into_connection(); | |
| 395 | - let svc = FediverseTrustService::new(db); | |
| 396 | - assert!(svc.is_domain_allowed("Partner.TLD").await.unwrap()); | |
| 397 | - } | |
| 398 | - | |
| 399 | - #[tokio::test] | |
| 400 | - async fn is_domain_allowed_returns_false_when_not_in_allowlist() { | |
| 401 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 402 | - .append_query_results([vec![enabled_setting("true")]]) | |
| 403 | - .append_query_results([vec![mode_setting("allowlist")]]) | |
| 404 | - .append_query_results([Vec::<fediverse_trusted_domain::Model>::new()]) | |
| 405 | - .into_connection(); | |
| 406 | - let svc = FediverseTrustService::new(db); | |
| 407 | - assert!(!svc.is_domain_allowed("stranger.tld").await.unwrap()); | |
| 408 | - } | |
| 409 | - | |
| 410 | - #[tokio::test] | |
| 411 | - async fn is_domain_allowed_returns_true_in_open_mode_when_not_blocked() { | |
| 412 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 413 | - .append_query_results([vec![enabled_setting("true")]]) | |
| 414 | - .append_query_results([vec![mode_setting("open_with_blocklist")]]) | |
| 415 | - .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 416 | - .into_connection(); | |
| 417 | - let svc = FediverseTrustService::new(db); | |
| 418 | - assert!(svc.is_domain_allowed("anybody.tld").await.unwrap()); | |
| 419 | - } | |
| 420 | - | |
| 421 | - #[tokio::test] | |
| 422 | - async fn is_domain_allowed_returns_false_in_open_mode_when_blocked() { | |
| 423 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 424 | - .append_query_results([vec![enabled_setting("true")]]) | |
| 425 | - .append_query_results([vec![mode_setting("open_with_blocklist")]]) | |
| 426 | - .append_query_results([vec![blocked_row("evil.tld")]]) | |
| 427 | - .into_connection(); | |
| 428 | - let svc = FediverseTrustService::new(db); | |
| 429 | - assert!(!svc.is_domain_allowed("evil.tld").await.unwrap()); | |
| 430 | - } | |
| 431 | - | |
| 432 | - #[tokio::test] | |
| 433 | - async fn add_trusted_domain_inserts_record() { | |
| 434 | - let row = trusted_row("partner.tld"); | |
| 435 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 436 | - .append_query_results([vec![row.clone()]]) | |
| 437 | - .append_exec_results([MockExecResult { | |
| 438 | - last_insert_id: 0, | |
| 439 | - rows_affected: 1, | |
| 440 | - }]) | |
| 441 | - .into_connection(); | |
| 442 | - let svc = FediverseTrustService::new(db); | |
| 443 | - let inserted = svc | |
| 444 | - .add_trusted_domain("Partner.TLD", Some("partnership"), None) | |
| 445 | - .await | |
| 446 | - .unwrap(); | |
| 447 | - assert_eq!(inserted.domain, "partner.tld"); | |
| 448 | - } | |
| 449 | -} |
M
crates/rustwarden-core/src/services/mod.rs
+2
-27
@@ -41,33 +41,8 @@ pub mod personal_token_service;
| 41 | 41 | pub mod rate_limit_service; |
| 42 | 42 | /// Refresh token rotation and revocation service. |
| 43 | 43 | pub mod refresh_token_service; |
| 44 | -/// Remote ActivityPub identity store + resolver (RFC 0003, | |
| 45 | -/// gated by `experimental-fediverse`). | |
| 46 | -#[cfg(feature = "experimental-fediverse")] | |
| 47 | -pub mod remote_identity_service; | |
| 48 | -/// Federation trust model (RFC 0004, gated by `experimental-fediverse`). | |
| 49 | -#[cfg(feature = "experimental-fediverse")] | |
| 50 | -pub mod fediverse_trust_service; | |
| 51 | -/// Local actor RSA key generation and encrypted storage (RFC 0005, | |
| 52 | -/// gated by `experimental-fediverse`). | |
| 53 | -#[cfg(feature = "experimental-fediverse")] | |
| 54 | -pub mod actor_key_service; | |
| 55 | -/// Outbound ActivityPub delivery queue + worker (RFC 0011, | |
| 56 | -/// gated by `experimental-fediverse`). | |
| 57 | -#[cfg(feature = "experimental-fediverse")] | |
| 58 | -pub mod fediverse_outbound_service; | |
| 59 | -/// AP-native authentication flow service (RFC 0006, | |
| 60 | -/// gated by `experimental-fediverse`). | |
| 61 | -#[cfg(feature = "experimental-fediverse")] | |
| 62 | -pub mod fediverse_auth_service; | |
| 63 | -/// Cross-instance team-membership invitations (RFC 0009, | |
| 64 | -/// gated by `experimental-fediverse`). | |
| 65 | -#[cfg(feature = "experimental-fediverse")] | |
| 66 | -pub mod fediverse_invite_service; | |
| 67 | -/// Fediverse lifecycle signal handlers (RFC 0010, | |
| 68 | -/// gated by `experimental-fediverse`). | |
| 69 | -#[cfg(feature = "experimental-fediverse")] | |
| 70 | -pub mod fediverse_signal_service; | |
| 44 | +// Federation services (`fediverse_*_service`, `actor_key_service`, | |
| 45 | +// `remote_identity_service`) moved to the `rustwarden-fediverse` crate. | |
| 71 | 46 | /// Generic resource registration and sharing service. |
| 72 | 47 | pub mod resource_service; |
| 73 | 48 | /// Role assignment and lookup service. |
D
crates/rustwarden-core/src/services/remote_identity_service.rs
+0
-435
@@ -1,435 +0,0 @@
| 1 | -//! Remote ActivityPub identity service (RFC 0003). | |
| 2 | -//! | |
| 3 | -//! Caches remote actors in the `remote_identities` table and exposes | |
| 4 | -//! `resolve_handle` which performs WebFinger + actor fetch when the cache | |
| 5 | -//! is stale or empty. | |
| 6 | -//! | |
| 7 | -//! Gated by `experimental-fediverse`. | |
| 8 | - | |
| 9 | -use std::sync::Arc; | |
| 10 | -use std::time::Duration; | |
| 11 | - | |
| 12 | -use chrono::{DateTime, Utc}; | |
| 13 | -use sea_orm::DatabaseConnection; | |
| 14 | -use uuid::Uuid; | |
| 15 | - | |
| 16 | -use crate::config::FediverseConfig; | |
| 17 | -use crate::error::AppError; | |
| 18 | -use crate::fediverse::error::FediverseError; | |
| 19 | -use crate::fediverse::fetcher::RemoteFetcher; | |
| 20 | -use crate::fediverse::types::PersonJson; | |
| 21 | -use crate::models::remote_identity; | |
| 22 | - | |
| 23 | -/// Service orchestrating remote actor resolution and caching. | |
| 24 | -/// | |
| 25 | -/// Constructed by consumers (RFCs 0008+ for invitations / Identity | |
| 26 | -/// abstraction will inject one). Cheap to clone — the DB handle is | |
| 27 | -/// `Arc`-backed and the fetcher is behind `Arc<dyn RemoteFetcher>`. | |
| 28 | -#[derive(Clone)] | |
| 29 | -pub struct RemoteIdentityService { | |
| 30 | - db: DatabaseConnection, | |
| 31 | - fetcher: Arc<dyn RemoteFetcher>, | |
| 32 | - cache_ttl: Duration, | |
| 33 | -} | |
| 34 | - | |
| 35 | -impl RemoteIdentityService { | |
| 36 | - /// Build a new service with the given storage backend, fetcher, and | |
| 37 | - /// federation config. | |
| 38 | - pub fn new( | |
| 39 | - db: DatabaseConnection, | |
| 40 | - fetcher: Arc<dyn RemoteFetcher>, | |
| 41 | - config: &FediverseConfig, | |
| 42 | - ) -> Self { | |
| 43 | - Self { | |
| 44 | - db, | |
| 45 | - fetcher, | |
| 46 | - cache_ttl: config.fetch_actor_cache_ttl(), | |
| 47 | - } | |
| 48 | - } | |
| 49 | - | |
| 50 | - /// Return `true` when the cache row is older than the configured TTL | |
| 51 | - /// and should be refreshed. | |
| 52 | - pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool { | |
| 53 | - let elapsed = Utc::now().signed_duration_since(last_fetched_at); | |
| 54 | - match elapsed.to_std() { | |
| 55 | - Ok(d) => d >= self.cache_ttl, | |
| 56 | - // Negative durations: clock skew, treat as fresh. | |
| 57 | - Err(_) => false, | |
| 58 | - } | |
| 59 | - } | |
| 60 | - | |
| 61 | - /// Resolve a `@user@domain` handle. Returns the cached row if fresh; | |
| 62 | - /// otherwise calls the fetcher (WebFinger + actor JSON) and upserts. | |
| 63 | - pub async fn resolve_handle( | |
| 64 | - &self, | |
| 65 | - handle: &str, | |
| 66 | - ) -> Result<remote_identity::Model, FediverseError> { | |
| 67 | - let (username, domain) = parse_handle(handle)?; | |
| 68 | - | |
| 69 | - // Fast path: cache hit and fresh. | |
| 70 | - if let Some(cached) = self.find_by_handle(&username, &domain).await? { | |
| 71 | - if !self.is_stale(cached.last_fetched_at) { | |
| 72 | - return Ok(cached); | |
| 73 | - } | |
| 74 | - } | |
| 75 | - | |
| 76 | - // Slow path: fetch via WebFinger then by actor URL, then upsert. | |
| 77 | - let webfinger = self.fetcher.webfinger(handle).await?; | |
| 78 | - let actor_url = webfinger | |
| 79 | - .links | |
| 80 | - .iter() | |
| 81 | - .find(|l| { | |
| 82 | - l.rel == "self" | |
| 83 | - && l.media_type | |
| 84 | - .as_deref() | |
| 85 | - .is_some_and(|t| t.contains("activity+json") || t.contains("ld+json")) | |
| 86 | - }) | |
| 87 | - .and_then(|l| l.href.as_ref()) | |
| 88 | - .ok_or_else(|| { | |
| 89 | - FediverseError::InvalidResource(format!( | |
| 90 | - "WebFinger response for {handle} has no self/AP link" | |
| 91 | - )) | |
| 92 | - })?; | |
| 93 | - | |
| 94 | - let actor_url = url::Url::parse(actor_url).map_err(|e| { | |
| 95 | - FediverseError::InvalidResource(format!("invalid self link in WebFinger: {e}")) | |
| 96 | - })?; | |
| 97 | - let person = self.fetcher.fetch_actor(&actor_url).await?; | |
| 98 | - self.upsert_from_person(person, &domain).await | |
| 99 | - } | |
| 100 | - | |
| 101 | - /// Direct DB lookup by AP id. Returns `None` if no row matches. | |
| 102 | - pub async fn find_by_uri( | |
| 103 | - &self, | |
| 104 | - ap_id: &str, | |
| 105 | - ) -> Result<Option<remote_identity::Model>, AppError> { | |
| 106 | - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 107 | - Ok(remote_identity::Entity::find() | |
| 108 | - .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 109 | - .one(&self.db) | |
| 110 | - .await?) | |
| 111 | - } | |
| 112 | - | |
| 113 | - /// Direct DB lookup by handle. Returns `None` if no row matches. | |
| 114 | - pub async fn find_by_handle( | |
| 115 | - &self, | |
| 116 | - username: &str, | |
| 117 | - domain: &str, | |
| 118 | - ) -> Result<Option<remote_identity::Model>, AppError> { | |
| 119 | - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 120 | - Ok(remote_identity::Entity::find() | |
| 121 | - .filter(remote_identity::Column::Username.eq(username)) | |
| 122 | - .filter(remote_identity::Column::Domain.eq(domain)) | |
| 123 | - .one(&self.db) | |
| 124 | - .await?) | |
| 125 | - } | |
| 126 | - | |
| 127 | - /// Insert or update a remote identity from a freshly fetched PersonJson. | |
| 128 | - /// | |
| 129 | - /// `expected_domain` MUST match the host of `person.id`. Mismatches yield | |
| 130 | - /// [`FediverseError::DomainMismatch`] without touching the database, to | |
| 131 | - /// prevent trivial spoofing. | |
| 132 | - pub async fn upsert_from_person( | |
| 133 | - &self, | |
| 134 | - person: PersonJson, | |
| 135 | - expected_domain: &str, | |
| 136 | - ) -> Result<remote_identity::Model, FediverseError> { | |
| 137 | - use sea_orm::{ActiveModelTrait, Set}; | |
| 138 | - | |
| 139 | - let actor_host = person | |
| 140 | - .id | |
| 141 | - .host_str() | |
| 142 | - .ok_or_else(|| FediverseError::InvalidResource("actor id has no host".into()))?; | |
| 143 | - if !actor_host.eq_ignore_ascii_case(expected_domain) { | |
| 144 | - return Err(FediverseError::DomainMismatch); | |
| 145 | - } | |
| 146 | - | |
| 147 | - let raw_json = serde_json::to_value(&person).ok(); | |
| 148 | - let now = Utc::now(); | |
| 149 | - let public_key_pem = person | |
| 150 | - .public_key | |
| 151 | - .as_ref() | |
| 152 | - .map(|k| k.public_key_pem.clone()) | |
| 153 | - .unwrap_or_default(); | |
| 154 | - | |
| 155 | - if let Some(existing) = self | |
| 156 | - .find_by_uri(person.id.as_str()) | |
| 157 | - .await | |
| 158 | - .map_err(FediverseError::App)? | |
| 159 | - { | |
| 160 | - let mut active: remote_identity::ActiveModel = existing.into(); | |
| 161 | - active.username = Set(person.preferred_username.clone()); | |
| 162 | - active.domain = Set(actor_host.to_string()); | |
| 163 | - active.display_name = Set(person.name.clone()); | |
| 164 | - active.summary_html = Set(person.summary.clone()); | |
| 165 | - active.inbox_url = Set(person.inbox.to_string()); | |
| 166 | - active.shared_inbox_url = Set(person | |
| 167 | - .endpoints | |
| 168 | - .as_ref() | |
| 169 | - .map(|e| e.shared_inbox.to_string())); | |
| 170 | - active.public_key_pem = Set(public_key_pem); | |
| 171 | - active.actor_type = Set(person.kind.clone()); | |
| 172 | - active.raw_json = Set(raw_json); | |
| 173 | - active.last_fetched_at = Set(now); | |
| 174 | - active.updated_at = Set(now); | |
| 175 | - Ok(active.update(&self.db).await.map_err(AppError::from)?) | |
| 176 | - } else { | |
| 177 | - let active = remote_identity::ActiveModel { | |
| 178 | - id: Set(Uuid::new_v4()), | |
| 179 | - ap_id: Set(person.id.to_string()), | |
| 180 | - username: Set(person.preferred_username.clone()), | |
| 181 | - domain: Set(actor_host.to_string()), | |
| 182 | - display_name: Set(person.name.clone()), | |
| 183 | - summary_html: Set(person.summary.clone()), | |
| 184 | - inbox_url: Set(person.inbox.to_string()), | |
| 185 | - shared_inbox_url: Set(person | |
| 186 | - .endpoints | |
| 187 | - .as_ref() | |
| 188 | - .map(|e| e.shared_inbox.to_string())), | |
| 189 | - public_key_pem: Set(public_key_pem), | |
| 190 | - icon_url: Set(None), | |
| 191 | - actor_type: Set(person.kind.clone()), | |
| 192 | - raw_json: Set(raw_json), | |
| 193 | - last_fetched_at: Set(now), | |
| 194 | - disabled_at: Set(None), | |
| 195 | - created_at: Set(now), | |
| 196 | - updated_at: Set(now), | |
| 197 | - }; | |
| 198 | - Ok(active.insert(&self.db).await.map_err(AppError::from)?) | |
| 199 | - } | |
| 200 | - } | |
| 201 | - | |
| 202 | - /// Mark an identity as disabled (used by `Delete{Actor}` in RFC 0010). | |
| 203 | - pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> { | |
| 204 | - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::Expr}; | |
| 205 | - | |
| 206 | - let now = Utc::now(); | |
| 207 | - remote_identity::Entity::update_many() | |
| 208 | - .col_expr( | |
| 209 | - remote_identity::Column::DisabledAt, | |
| 210 | - Expr::value(now), | |
| 211 | - ) | |
| 212 | - .col_expr( | |
| 213 | - remote_identity::Column::UpdatedAt, | |
| 214 | - Expr::value(now), | |
| 215 | - ) | |
| 216 | - .filter(remote_identity::Column::Id.eq(id)) | |
| 217 | - .exec(&self.db) | |
| 218 | - .await?; | |
| 219 | - // Touch unused-import lint | |
| 220 | - let _ = Set::<Option<DateTime<Utc>>>; | |
| 221 | - Ok(()) | |
| 222 | - } | |
| 223 | -} | |
| 224 | - | |
| 225 | -/// Parse a `@user@domain` handle into `(username, domain)`. | |
| 226 | -/// | |
| 227 | -/// Accepts both `@user@domain` (Mastodon convention) and bare `user@domain`. | |
| 228 | -/// Returns [`FediverseError::InvalidResource`] if the format is malformed. | |
| 229 | -pub(crate) fn parse_handle(handle: &str) -> Result<(String, String), FediverseError> { | |
| 230 | - let trimmed = handle.trim(); | |
| 231 | - let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); | |
| 232 | - let (user, domain) = stripped.split_once('@').ok_or_else(|| { | |
| 233 | - FediverseError::InvalidResource(format!("missing @ separator in handle {handle}")) | |
| 234 | - })?; | |
| 235 | - if user.is_empty() || domain.is_empty() { | |
| 236 | - return Err(FediverseError::InvalidResource(format!( | |
| 237 | - "empty user or domain in handle {handle}" | |
| 238 | - ))); | |
| 239 | - } | |
| 240 | - Ok((user.to_string(), domain.to_string())) | |
| 241 | -} | |
| 242 | - | |
| 243 | -#[cfg(test)] | |
| 244 | -mod tests { | |
| 245 | - use super::*; | |
| 246 | - use crate::fediverse::fetcher::UnconfiguredFetcher; | |
| 247 | - use chrono::Duration as ChronoDuration; | |
| 248 | - use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 249 | - | |
| 250 | - fn config_for_tests() -> FediverseConfig { | |
| 251 | - FediverseConfig::for_tests("example.org") | |
| 252 | - } | |
| 253 | - | |
| 254 | - fn make_service(db: DatabaseConnection) -> RemoteIdentityService { | |
| 255 | - RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()) | |
| 256 | - } | |
| 257 | - | |
| 258 | - fn sample_remote(domain: &str, username: &str) -> remote_identity::Model { | |
| 259 | - let now = Utc::now(); | |
| 260 | - let id = Uuid::new_v4(); | |
| 261 | - remote_identity::Model { | |
| 262 | - id, | |
| 263 | - ap_id: format!("https://{domain}/users/{username}"), | |
| 264 | - username: username.to_string(), | |
| 265 | - domain: domain.to_string(), | |
| 266 | - display_name: Some(username.to_string()), | |
| 267 | - summary_html: None, | |
| 268 | - inbox_url: format!("https://{domain}/users/{username}/inbox"), | |
| 269 | - shared_inbox_url: Some(format!("https://{domain}/inbox")), | |
| 270 | - public_key_pem: "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----" | |
| 271 | - .to_string(), | |
| 272 | - icon_url: None, | |
| 273 | - actor_type: "Person".to_string(), | |
| 274 | - raw_json: None, | |
| 275 | - last_fetched_at: now, | |
| 276 | - disabled_at: None, | |
| 277 | - created_at: now, | |
| 278 | - updated_at: now, | |
| 279 | - } | |
| 280 | - } | |
| 281 | - | |
| 282 | - // ─── Test 1 — is_stale fresh ───────────────────────────────────── | |
| 283 | - | |
| 284 | - #[test] | |
| 285 | - fn is_stale_returns_false_within_ttl() { | |
| 286 | - let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 287 | - let svc = make_service(db); | |
| 288 | - let recent = Utc::now() - ChronoDuration::seconds(60); | |
| 289 | - assert!(!svc.is_stale(recent)); | |
| 290 | - } | |
| 291 | - | |
| 292 | - // ─── Test 2 — is_stale stale ───────────────────────────────────── | |
| 293 | - | |
| 294 | - #[test] | |
| 295 | - fn is_stale_returns_true_past_ttl() { | |
| 296 | - let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 297 | - let svc = make_service(db); | |
| 298 | - // Default TTL is 86 400 s. Use 2 days to be safely past. | |
| 299 | - let old = Utc::now() - ChronoDuration::days(2); | |
| 300 | - assert!(svc.is_stale(old)); | |
| 301 | - } | |
| 302 | - | |
| 303 | - // ─── Test 3 — parse_handle ─────────────────────────────────────── | |
| 304 | - | |
| 305 | - #[test] | |
| 306 | - fn parse_handle_accepts_mastodon_form() { | |
| 307 | - let (user, domain) = parse_handle("@alice@example.org").expect("valid"); | |
| 308 | - assert_eq!(user, "alice"); | |
| 309 | - assert_eq!(domain, "example.org"); | |
| 310 | - } | |
| 311 | - | |
| 312 | - #[test] | |
| 313 | - fn parse_handle_accepts_bare_form() { | |
| 314 | - let (user, domain) = parse_handle("alice@example.org").expect("valid"); | |
| 315 | - assert_eq!(user, "alice"); | |
| 316 | - assert_eq!(domain, "example.org"); | |
| 317 | - } | |
| 318 | - | |
| 319 | - #[test] | |
| 320 | - fn parse_handle_rejects_missing_at() { | |
| 321 | - let err = parse_handle("alice").expect_err("must fail"); | |
| 322 | - assert!(matches!(err, FediverseError::InvalidResource(_))); | |
| 323 | - } | |
| 324 | - | |
| 325 | - // ─── Test 4 — find_by_uri none ─────────────────────────────────── | |
| 326 | - | |
| 327 | - #[tokio::test] | |
| 328 | - async fn find_by_uri_returns_none_when_absent() { | |
| 329 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 330 | - .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 331 | - .into_connection(); | |
| 332 | - let svc = make_service(db); | |
| 333 | - let result = svc.find_by_uri("https://other.tld/users/ghost").await.unwrap(); | |
| 334 | - assert!(result.is_none()); | |
| 335 | - } | |
| 336 | - | |
| 337 | - // ─── Test 5 — find_by_uri some ─────────────────────────────────── | |
| 338 | - | |
| 339 | - #[tokio::test] | |
| 340 | - async fn find_by_uri_returns_record_when_present() { | |
| 341 | - let row = sample_remote("other.tld", "alice"); | |
| 342 | - let expected_id = row.id; | |
| 343 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 344 | - .append_query_results([vec![row]]) | |
| 345 | - .into_connection(); | |
| 346 | - let svc = make_service(db); | |
| 347 | - let result = svc | |
| 348 | - .find_by_uri("https://other.tld/users/alice") | |
| 349 | - .await | |
| 350 | - .unwrap() | |
| 351 | - .expect("must be present"); | |
| 352 | - assert_eq!(result.id, expected_id); | |
| 353 | - } | |
| 354 | - | |
| 355 | - // ─── Test 6 — find_by_handle none ──────────────────────────────── | |
| 356 | - | |
| 357 | - #[tokio::test] | |
| 358 | - async fn find_by_handle_returns_none_when_absent() { | |
| 359 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 360 | - .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 361 | - .into_connection(); | |
| 362 | - let svc = make_service(db); | |
| 363 | - let result = svc.find_by_handle("ghost", "other.tld").await.unwrap(); | |
| 364 | - assert!(result.is_none()); | |
| 365 | - } | |
| 366 | - | |
| 367 | - // ─── Test 7 — upsert rejects domain mismatch ───────────────────── | |
| 368 | - | |
| 369 | - #[tokio::test] | |
| 370 | - async fn upsert_rejects_domain_mismatch() { | |
| 371 | - let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 372 | - let svc = make_service(db); | |
| 373 | - | |
| 374 | - let person = PersonJson { | |
| 375 | - context: crate::fediverse::types::actor_context(), | |
| 376 | - kind: "Person".into(), | |
| 377 | - id: url::Url::parse("https://attacker.tld/users/alice").unwrap(), | |
| 378 | - preferred_username: "alice".into(), | |
| 379 | - name: None, | |
| 380 | - summary: None, | |
| 381 | - url: None, | |
| 382 | - inbox: url::Url::parse("https://attacker.tld/users/alice/inbox").unwrap(), | |
| 383 | - outbox: url::Url::parse("https://attacker.tld/users/alice/outbox").unwrap(), | |
| 384 | - followers: url::Url::parse("https://attacker.tld/users/alice/followers").unwrap(), | |
| 385 | - following: url::Url::parse("https://attacker.tld/users/alice/following").unwrap(), | |
| 386 | - endpoints: None, | |
| 387 | - public_key: None, | |
| 388 | - manually_approves_followers: false, | |
| 389 | - discoverable: true, | |
| 390 | - }; | |
| 391 | - | |
| 392 | - let err = svc | |
| 393 | - .upsert_from_person(person, "expected.tld") | |
| 394 | - .await | |
| 395 | - .expect_err("must fail"); | |
| 396 | - assert!( | |
| 397 | - matches!(err, FediverseError::DomainMismatch), | |
| 398 | - "got {err:?}" | |
| 399 | - ); | |
| 400 | - } | |
| 401 | - | |
| 402 | - // ─── Test 8 — mark_disabled ────────────────────────────────────── | |
| 403 | - | |
| 404 | - #[tokio::test] | |
| 405 | - async fn mark_disabled_sets_disabled_at() { | |
| 406 | - let id = Uuid::new_v4(); | |
| 407 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 408 | - .append_exec_results([MockExecResult { | |
| 409 | - last_insert_id: 0, | |
| 410 | - rows_affected: 1, | |
| 411 | - }]) | |
| 412 | - .into_connection(); | |
| 413 | - let svc = make_service(db); | |
| 414 | - svc.mark_disabled(id).await.unwrap(); | |
| 415 | - } | |
| 416 | - | |
| 417 | - // ─── Test 9 — resolve_handle returns cached when fresh ─────────── | |
| 418 | - | |
| 419 | - #[tokio::test] | |
| 420 | - async fn resolve_handle_returns_cached_when_fresh() { | |
| 421 | - let row = sample_remote("other.tld", "alice"); | |
| 422 | - let expected_id = row.id; | |
| 423 | - let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 424 | - .append_query_results([vec![row]]) | |
| 425 | - .into_connection(); | |
| 426 | - // UnconfiguredFetcher would return Err if invoked — fresh cache must | |
| 427 | - // not invoke it. | |
| 428 | - let svc = RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()); | |
| 429 | - let resolved = svc | |
| 430 | - .resolve_handle("@alice@other.tld") | |
| 431 | - .await | |
| 432 | - .expect("cached row"); | |
| 433 | - assert_eq!(resolved.id, expected_id); | |
| 434 | - } | |
| 435 | -} |
M
crates/rustwarden-core/src/services/team_service.rs
+0
-4
@@ -181,7 +181,6 @@ impl TeamService {
| 181 | 181 | /// partial index `team_members_remote_unique` (added by migration |
| 182 | 182 | /// `m20260510_000015`) prevents duplicate remote rows for the same |
| 183 | 183 | /// (team, identity) pair. |
| 184 | - #[cfg(feature = "experimental-fediverse")] | |
| 185 | 184 | pub async fn add_member_identity( |
| 186 | 185 | db: &DatabaseConnection, |
| 187 | 186 | team_id: Uuid, |
@@ -210,7 +209,6 @@ impl TeamService {
| 210 | 209 | /// RFC 0009: every team the [`Identity`] belongs to. Local |
| 211 | 210 | /// identities re-use [`Self::teams_of_user`]; remote identities |
| 212 | 211 | /// are looked up via `team_members.remote_identity_id`. |
| 213 | - #[cfg(feature = "experimental-fediverse")] | |
| 214 | 212 | pub async fn teams_of_identity( |
| 215 | 213 | db: &DatabaseConnection, |
| 216 | 214 | identity: crate::identity::Identity, |
@@ -585,7 +583,6 @@ mod tests {
| 585 | 583 | |
| 586 | 584 | // ─── RFC 0009 — add_member_identity ───────────────────────────── |
| 587 | 585 | |
| 588 | - #[cfg(feature = "experimental-fediverse")] | |
| 589 | 586 | #[tokio::test] |
| 590 | 587 | async fn rfc0009_add_member_identity_local_writes_user_id_column() { |
| 591 | 588 | use crate::identity::Identity; |
@@ -608,7 +605,6 @@ mod tests {
| 608 | 605 | assert!(row.remote_identity_id.is_none()); |
| 609 | 606 | } |
| 610 | 607 | |
| 611 | - #[cfg(feature = "experimental-fediverse")] | |
| 612 | 608 | #[tokio::test] |
| 613 | 609 | async fn rfc0009_add_member_identity_remote_writes_remote_identity_id_column() { |
| 614 | 610 | use crate::identity::Identity; |
D
crates/rustwarden-core/tests/e2e_fediverse.rs
+0
-1982
@@ -1,1982 +0,0 @@
| 1 | -//! End-to-end smoke tests against the docker-compose fediverse testbed. | |
| 2 | -//! | |
| 3 | -//! Run with: | |
| 4 | -//! ```text | |
| 5 | -//! cd tests/fediverse-testbed && ./scripts/setup.sh | |
| 6 | -//! cargo test -p rustwarden-core --test e2e_fediverse \ | |
| 7 | -//! --features experimental-fediverse | |
| 8 | -//! ``` | |
| 9 | -//! | |
| 10 | -//! Tests skip gracefully (without failing) if the testbed is not running, so | |
| 11 | -//! they can stay in the default test suite without breaking developers who | |
| 12 | -//! haven't booted the testbed. | |
| 13 | - | |
| 14 | -#![cfg(feature = "experimental-fediverse")] | |
| 15 | - | |
| 16 | -use base64::Engine; | |
| 17 | -use reqwest::{Client, StatusCode}; | |
| 18 | -use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 19 | -use rustwarden_core::services::crypto_service::CryptoService; | |
| 20 | -use serde_json::{json, Value}; | |
| 21 | -use std::process::Command; | |
| 22 | -use uuid::Uuid; | |
| 23 | - | |
| 24 | -const ALPHA: &str = "http://localhost:3001"; | |
| 25 | -const BETA: &str = "http://localhost:3002"; | |
| 26 | -const GAMMA: &str = "http://localhost:3003"; | |
| 27 | - | |
| 28 | -const INSTANCES: &[(&str, &str)] = &[ | |
| 29 | - (ALPHA, "alpha.local"), | |
| 30 | - (BETA, "beta.local"), | |
| 31 | - (GAMMA, "gamma.local"), | |
| 32 | -]; | |
| 33 | - | |
| 34 | -/// Probe `/health` on the three instances. Returns a configured client if | |
| 35 | -/// every probe succeeds, `None` otherwise (printing a hint for the developer). | |
| 36 | -async fn testbed_or_skip() -> Option<Client> { | |
| 37 | - let client = Client::builder() | |
| 38 | - .timeout(std::time::Duration::from_secs(5)) | |
| 39 | - .build() | |
| 40 | - .expect("reqwest client builder must not fail"); | |
| 41 | - | |
| 42 | - for (base, _) in INSTANCES { | |
| 43 | - match client.get(format!("{base}/health")).send().await { | |
| 44 | - Ok(r) if r.status() == StatusCode::OK => continue, | |
| 45 | - other => { | |
| 46 | - eprintln!( | |
| 47 | - "[e2e_fediverse] testbed unreachable at {base}: {other:?}\n\ | |
| 48 | - [e2e_fediverse] start with: tests/fediverse-testbed/scripts/setup.sh\n\ | |
| 49 | - [e2e_fediverse] skipping E2E test." | |
| 50 | - ); | |
| 51 | - return None; | |
| 52 | - } | |
| 53 | - } | |
| 54 | - } | |
| 55 | - Some(client) | |
| 56 | -} | |
| 57 | - | |
| 58 | -#[tokio::test] | |
| 59 | -async fn webfinger_resolves_local_user_on_each_instance() { | |
| 60 | - let Some(client) = testbed_or_skip().await else { | |
| 61 | - return; | |
| 62 | - }; | |
| 63 | - for (base, domain) in INSTANCES { | |
| 64 | - let response = client | |
| 65 | - .get(format!("{base}/.well-known/webfinger")) | |
| 66 | - .query(&[("resource", format!("acct:joanie@{domain}"))]) | |
| 67 | - .send() | |
| 68 | - .await | |
| 69 | - .expect("request must succeed"); | |
| 70 | - assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 71 | - let jrd: Value = response.json().await.expect("body must be JSON"); | |
| 72 | - assert_eq!(jrd["subject"], format!("acct:joanie@{domain}")); | |
| 73 | - let links = jrd["links"].as_array().expect("links must be array"); | |
| 74 | - let has_self_ap = links.iter().any(|l| { | |
| 75 | - l["rel"] == "self" && l["type"] == "application/activity+json" | |
| 76 | - }); | |
| 77 | - assert!(has_self_ap, "JRD on {base} must expose a self link with AP type"); | |
| 78 | - } | |
| 79 | -} | |
| 80 | - | |
| 81 | -#[tokio::test] | |
| 82 | -async fn webfinger_returns_404_for_foreign_domain() { | |
| 83 | - let Some(client) = testbed_or_skip().await else { | |
| 84 | - return; | |
| 85 | - }; | |
| 86 | - // Alpha must not vouch for an account on beta's domain. | |
| 87 | - let response = client | |
| 88 | - .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 89 | - .query(&[("resource", "acct:joanie@beta.local")]) | |
| 90 | - .send() | |
| 91 | - .await | |
| 92 | - .expect("request must succeed"); | |
| 93 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 94 | -} | |
| 95 | - | |
| 96 | -#[tokio::test] | |
| 97 | -async fn webfinger_returns_4xx_for_malformed_resource() { | |
| 98 | - let Some(client) = testbed_or_skip().await else { | |
| 99 | - return; | |
| 100 | - }; | |
| 101 | - let response = client | |
| 102 | - .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 103 | - .query(&[("resource", "not-an-acct-uri")]) | |
| 104 | - .send() | |
| 105 | - .await | |
| 106 | - .expect("request must succeed"); | |
| 107 | - assert!( | |
| 108 | - response.status().is_client_error(), | |
| 109 | - "expected 4xx for malformed resource, got {}", | |
| 110 | - response.status() | |
| 111 | - ); | |
| 112 | -} | |
| 113 | - | |
| 114 | -#[tokio::test] | |
| 115 | -async fn actor_fetch_returns_valid_person_jsonld() { | |
| 116 | - let Some(client) = testbed_or_skip().await else { | |
| 117 | - return; | |
| 118 | - }; | |
| 119 | - for (base, domain) in INSTANCES { | |
| 120 | - let response = client | |
| 121 | - .get(format!("{base}/users/joanie")) | |
| 122 | - .header("Accept", "application/activity+json") | |
| 123 | - .send() | |
| 124 | - .await | |
| 125 | - .expect("request must succeed"); | |
| 126 | - assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 127 | - let actor: Value = response.json().await.expect("body must be JSON"); | |
| 128 | - assert_eq!(actor["type"], "Person"); | |
| 129 | - assert_eq!(actor["preferredUsername"], "joanie"); | |
| 130 | - assert_eq!(actor["id"], format!("http://{domain}/users/joanie")); | |
| 131 | - assert_eq!( | |
| 132 | - actor["inbox"], | |
| 133 | - format!("http://{domain}/users/joanie/inbox") | |
| 134 | - ); | |
| 135 | - let key = &actor["publicKey"]; | |
| 136 | - assert!( | |
| 137 | - key["publicKeyPem"] | |
| 138 | - .as_str() | |
| 139 | - .unwrap_or_default() | |
| 140 | - .contains("BEGIN PUBLIC KEY"), | |
| 141 | - "actor on {base} must expose a PEM publicKey" | |
| 142 | - ); | |
| 143 | - } | |
| 144 | -} | |
| 145 | - | |
| 146 | -#[tokio::test] | |
| 147 | -async fn nodeinfo_discovery_returns_links() { | |
| 148 | - let Some(client) = testbed_or_skip().await else { | |
| 149 | - return; | |
| 150 | - }; | |
| 151 | - let response = client | |
| 152 | - .get(format!("{ALPHA}/.well-known/nodeinfo")) | |
| 153 | - .send() | |
| 154 | - .await | |
| 155 | - .expect("request must succeed"); | |
| 156 | - assert_eq!(response.status(), StatusCode::OK); | |
| 157 | - let body: Value = response.json().await.expect("body must be JSON"); | |
| 158 | - let links = body["links"].as_array().expect("links must be array"); | |
| 159 | - assert!( | |
| 160 | - links.iter().any(|l| l["rel"] | |
| 161 | - .as_str() | |
| 162 | - .unwrap_or_default() | |
| 163 | - .contains("nodeinfo")), | |
| 164 | - "nodeinfo discovery must expose at least one nodeinfo link" | |
| 165 | - ); | |
| 166 | -} | |
| 167 | - | |
| 168 | -#[tokio::test] | |
| 169 | -async fn auth_inbox_rejects_unsigned_post() { | |
| 170 | - let Some(client) = testbed_or_skip().await else { | |
| 171 | - return; | |
| 172 | - }; | |
| 173 | - // No Signature header, no Date, no Digest — must be refused. | |
| 174 | - let response = client | |
| 175 | - .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 176 | - .header("Content-Type", "application/activity+json") | |
| 177 | - .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 178 | - .send() | |
| 179 | - .await | |
| 180 | - .expect("request must succeed"); | |
| 181 | - assert!( | |
| 182 | - matches!( | |
| 183 | - response.status(), | |
| 184 | - StatusCode::UNAUTHORIZED | StatusCode::BAD_REQUEST | |
| 185 | - ), | |
| 186 | - "unsigned inbox POST must be 4xx, got {}", | |
| 187 | - response.status() | |
| 188 | - ); | |
| 189 | -} | |
| 190 | - | |
| 191 | -// ─────────────────────────────────────────────────────────────────────── | |
| 192 | -// Iteration 1b: S1 — SSO bilateral signed request, real HTTP, real crypto | |
| 193 | -// ─────────────────────────────────────────────────────────────────────── | |
| 194 | - | |
| 195 | -/// Beta's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/beta.env`. | |
| 196 | -/// Testbed-only: never reuse anywhere else. | |
| 197 | -const BETA_ENCRYPTION_KEY: &str = | |
| 198 | - "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; | |
| 199 | - | |
| 200 | -/// Alpha's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/alpha.env`. | |
| 201 | -const ALPHA_ENCRYPTION_KEY: &str = | |
| 202 | - "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; | |
| 203 | - | |
| 204 | -/// Absolute path to the testbed directory (compose file lives here). | |
| 205 | -const TESTBED_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/fediverse-testbed"); | |
| 206 | - | |
| 207 | -/// Run a SQL statement on a testbed instance via `docker compose exec psql`. | |
| 208 | -/// Returns trimmed stdout. Panics on non-zero exit. | |
| 209 | -fn psql(instance: &str, sql: &str) -> String { | |
| 210 | - let output = Command::new("docker") | |
| 211 | - .args([ | |
| 212 | - "compose", | |
| 213 | - "exec", | |
| 214 | - "-T", | |
| 215 | - &format!("postgres-{instance}"), | |
| 216 | - "psql", | |
| 217 | - "-U", | |
| 218 | - "rustwarden", | |
| 219 | - "-d", | |
| 220 | - &format!("rustwarden_{instance}"), | |
| 221 | - "-tA", | |
| 222 | - "-c", | |
| 223 | - sql, | |
| 224 | - ]) | |
| 225 | - .current_dir(TESTBED_DIR) | |
| 226 | - .output() | |
| 227 | - .expect("docker compose exec must be runnable"); | |
| 228 | - assert!( | |
| 229 | - output.status.success(), | |
| 230 | - "psql {instance} failed: {}", | |
| 231 | - String::from_utf8_lossy(&output.stderr) | |
| 232 | - ); | |
| 233 | - String::from_utf8_lossy(&output.stdout).trim().to_string() | |
| 234 | -} | |
| 235 | - | |
| 236 | -/// Reqwest client with custom DNS so that `http://alpha.local/...` connects | |
| 237 | -/// to `127.0.0.1:3001` while sending `Host: alpha.local`. Necessary because | |
| 238 | -/// the signed signing string must match the Host the server actually sees. | |
| 239 | -fn signing_client() -> Client { | |
| 240 | - Client::builder() | |
| 241 | - .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 242 | - .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 243 | - .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 244 | - .timeout(std::time::Duration::from_secs(5)) | |
| 245 | - .build() | |
| 246 | - .expect("reqwest client builder must not fail") | |
| 247 | -} | |
| 248 | - | |
| 249 | -/// Build the three headers (Date, Host, Signature) that a signed POST to | |
| 250 | -/// `target_url` (e.g. `http://alpha.local/fediverse/auth/inbox`) must carry, | |
| 251 | -/// per draft-cavage with `(request-target) host date`. | |
| 252 | -/// | |
| 253 | -/// `actor_url` is the *non-fragmented* AP actor URL of the requester; the | |
| 254 | -/// `#main-key` fragment is appended automatically to match what | |
| 255 | -/// [`HttpSignedDelivery::deliver`] emits. | |
| 256 | -fn signed_headers( | |
| 257 | - private_pem: &str, | |
| 258 | - actor_url: &str, | |
| 259 | - target_url: &str, | |
| 260 | -) -> (String, String, String) { | |
| 261 | - let target = url::Url::parse(target_url).expect("target_url must parse"); | |
| 262 | - let host = target.host_str().expect("target_url must have host").to_string(); | |
| 263 | - let path = target.path().to_string(); | |
| 264 | - | |
| 265 | - let date = chrono::Utc::now() | |
| 266 | - .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 267 | - .to_string(); | |
| 268 | - let signed = vec![ | |
| 269 | - "(request-target)".to_string(), | |
| 270 | - "host".to_string(), | |
| 271 | - "date".to_string(), | |
| 272 | - ]; | |
| 273 | - let request_target = format!("post {path}"); | |
| 274 | - | |
| 275 | - let host_for_input = host.clone(); | |
| 276 | - let date_for_input = date.clone(); | |
| 277 | - let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 278 | - "host" => Some(host_for_input.clone()), | |
| 279 | - "date" => Some(date_for_input.clone()), | |
| 280 | - _ => None, | |
| 281 | - }) | |
| 282 | - .expect("build_signature_input must succeed"); | |
| 283 | - | |
| 284 | - let sig_bytes = rsa_sha256_sign(private_pem, input.as_bytes()) | |
| 285 | - .expect("rsa_sha256_sign must succeed"); | |
| 286 | - let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 287 | - | |
| 288 | - let sig_header = format!( | |
| 289 | - r#"keyId="{actor_url}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""# | |
| 290 | - ); | |
| 291 | - (date, host, sig_header) | |
| 292 | -} | |
| 293 | - | |
| 294 | -/// Material the test needs to act on behalf of joanie@beta against alpha. | |
| 295 | -struct JoanieBeta { | |
| 296 | - private_pem: String, | |
| 297 | - actor_url: String, | |
| 298 | -} | |
| 299 | - | |
| 300 | -/// Fetch joanie@beta's actor (which lazy-generates RSA keys server-side) and | |
| 301 | -/// extract her decrypted private PEM from beta's DB. | |
| 302 | -async fn joanie_beta_keys(client: &Client) -> JoanieBeta { | |
| 303 | - let actor: Value = client | |
| 304 | - .get(format!("{BETA}/users/joanie")) | |
| 305 | - .header("Accept", "application/activity+json") | |
| 306 | - .send() | |
| 307 | - .await | |
| 308 | - .expect("actor fetch must succeed") | |
| 309 | - .json() | |
| 310 | - .await | |
| 311 | - .expect("actor JSON must parse"); | |
| 312 | - let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 313 | - assert_eq!(actor_url, "http://beta.local/users/joanie"); | |
| 314 | - | |
| 315 | - let encrypted = psql( | |
| 316 | - "beta", | |
| 317 | - "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 318 | - ); | |
| 319 | - assert!(encrypted.starts_with("enc:")); | |
| 320 | - | |
| 321 | - let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 322 | - unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) }; | |
| 323 | - let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed"); | |
| 324 | - unsafe { | |
| 325 | - match prev { | |
| 326 | - Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 327 | - None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 328 | - } | |
| 329 | - } | |
| 330 | - assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 331 | - | |
| 332 | - JoanieBeta { | |
| 333 | - private_pem, | |
| 334 | - actor_url, | |
| 335 | - } | |
| 336 | -} | |
| 337 | - | |
| 338 | -/// Simulate the side-effect of the real signature verifier: insert a | |
| 339 | -/// `remote_identities` row on `target_instance` for the given remote actor. | |
| 340 | -/// The debug `AlwaysAcceptVerifier` used in the testbed lets requests through | |
| 341 | -/// without populating this cache, so the test does it explicitly. | |
| 342 | -fn upsert_remote_identity( | |
| 343 | - target_instance: &str, | |
| 344 | - actor_url: &str, | |
| 345 | - username: &str, | |
| 346 | - domain: &str, | |
| 347 | - inbox_url: &str, | |
| 348 | - public_pem: &str, | |
| 349 | -) { | |
| 350 | - // Escape single quotes so the PEM body is safe inside a SQL literal. | |
| 351 | - let pem_escaped = public_pem.replace('\'', "''"); | |
| 352 | - let sql = format!( | |
| 353 | - "INSERT INTO remote_identities | |
| 354 | - (id, ap_id, username, domain, inbox_url, public_key_pem, | |
| 355 | - actor_type, last_fetched_at, created_at, updated_at) | |
| 356 | - VALUES (gen_random_uuid(), '{actor_url}', '{username}', '{domain}', | |
| 357 | - '{inbox_url}', '{pem_escaped}', 'Person', NOW(), NOW(), NOW()) | |
| 358 | - ON CONFLICT (ap_id) DO UPDATE SET | |
| 359 | - public_key_pem = EXCLUDED.public_key_pem, | |
| 360 | - last_fetched_at = NOW(), | |
| 361 | - updated_at = NOW();" | |
| 362 | - ); | |
| 363 | - psql(target_instance, &sql); | |
| 364 | -} | |
| 365 | - | |
| 366 | -#[tokio::test] | |
| 367 | -async fn s1_signed_authrequest_yields_pending_challenge_on_alpha() { | |
| 368 | - let Some(_) = testbed_or_skip().await else { | |
| 369 | - return; | |
| 370 | - }; | |
| 371 | - let client = signing_client(); | |
| 372 | - | |
| 373 | - // 1. Get joanie@beta's keys (server-side generation + DB extraction). | |
| 374 | - let joanie = joanie_beta_keys(&client).await; | |
| 375 | - | |
| 376 | - // 2. (Finding F2 fix) — `AlwaysAcceptVerifier::with_resolver` now | |
| 377 | - // populates alpha's `remote_identities` cache on first sight of the | |
| 378 | - // actor as part of the inbox handling. Nothing to pre-seed here; | |
| 379 | - // the test exercises that exact contract. | |
| 380 | - | |
| 381 | - // 3. Snapshot alpha's pending challenge count. | |
| 382 | - let before: i64 = psql( | |
| 383 | - "alpha", | |
| 384 | - "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 385 | - ) | |
| 386 | - .parse() | |
| 387 | - .expect("count must parse"); | |
| 388 | - | |
| 389 | - // 3. Build a fresh, valid AuthRequest body. | |
| 390 | - let nonce = uuid::Uuid::new_v4().to_string(); | |
| 391 | - let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 392 | - let payload = json!({ | |
| 393 | - "type": "AuthRequest", | |
| 394 | - "actor": joanie.actor_url, | |
| 395 | - "target": "joanie@alpha.local", | |
| 396 | - "nonce": nonce, | |
| 397 | - "expiresAt": expires_at, | |
| 398 | - }); | |
| 399 | - let body_bytes = serde_json::to_vec(&payload).expect("payload must serialize"); | |
| 400 | - | |
| 401 | - // 4. Sign + POST. | |
| 402 | - let target_url = "http://alpha.local/fediverse/auth/inbox"; | |
| 403 | - let (date, host, sig) = signed_headers(&joanie.private_pem, &joanie.actor_url, target_url); | |
| 404 | - | |
| 405 | - let response = client | |
| 406 | - .post(target_url) | |
| 407 | - .header("Host", host) | |
| 408 | - .header("Date", date) | |
| 409 | - .header("Signature", sig) | |
| 410 | - .header("Content-Type", "application/activity+json") | |
| 411 | - .body(body_bytes) | |
| 412 | - .send() | |
| 413 | - .await | |
| 414 | - .expect("signed POST must complete"); | |
| 415 | - | |
| 416 | - let status = response.status(); | |
| 417 | - let body = response.text().await.unwrap_or_default(); | |
| 418 | - assert_eq!( | |
| 419 | - status, | |
| 420 | - StatusCode::ACCEPTED, | |
| 421 | - "alpha must accept the signed AuthRequest; got {status} body={body}" | |
| 422 | - ); | |
| 423 | - | |
| 424 | - // 5. Alpha must now know about joanie@beta as a remote identity. | |
| 425 | - let ri_count = psql( | |
| 426 | - "alpha", | |
| 427 | - "SELECT COUNT(*) FROM remote_identities WHERE ap_id='http://beta.local/users/joanie'", | |
| 428 | - ); | |
| 429 | - assert_eq!(ri_count, "1", "alpha must have 1 remote_identity for joanie@beta"); | |
| 430 | - | |
| 431 | - // 6. Alpha must have created a pending challenge for that nonce. | |
| 432 | - let after: i64 = psql( | |
| 433 | - "alpha", | |
| 434 | - "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 435 | - ) | |
| 436 | - .parse() | |
| 437 | - .expect("count must parse"); | |
| 438 | - assert_eq!( | |
| 439 | - after, | |
| 440 | - before + 1, | |
| 441 | - "alpha must have exactly one more pending challenge after the POST" | |
| 442 | - ); | |
| 443 | - | |
| 444 | - let nonce_present = psql( | |
| 445 | - "alpha", | |
| 446 | - &format!("SELECT COUNT(*) FROM fediverse_auth_challenges WHERE nonce='{nonce}'"), | |
| 447 | - ); | |
| 448 | - assert_eq!(nonce_present, "1", "the inserted challenge must carry our nonce"); | |
| 449 | -} | |
| 450 | - | |
| 451 | -// ─────────────────────────────────────────────────────────────────────── | |
| 452 | -// Iteration 1d: F3 — full S1 round-trip via Accept(AuthRequest) (RFC 0025) | |
| 453 | -// ─────────────────────────────────────────────────────────────────────── | |
| 454 | - | |
| 455 | -/// Fetch joanie@alpha's keys (mirror of `joanie_beta_keys`). Used to | |
| 456 | -/// sign `Accept(AuthRequest)` activities pretending to come from | |
| 457 | -/// alpha's UI consent click. | |
| 458 | -async fn joanie_alpha_keys(client: &Client) -> JoanieBeta { | |
| 459 | - let actor: Value = client | |
| 460 | - .get(format!("{ALPHA}/users/joanie")) | |
| 461 | - .header("Accept", "application/activity+json") | |
| 462 | - .send() | |
| 463 | - .await | |
| 464 | - .expect("alpha actor fetch must succeed") | |
| 465 | - .json() | |
| 466 | - .await | |
| 467 | - .expect("alpha actor JSON must parse"); | |
| 468 | - let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 469 | - assert_eq!(actor_url, "http://alpha.local/users/joanie"); | |
| 470 | - | |
| 471 | - let encrypted = psql( | |
| 472 | - "alpha", | |
| 473 | - "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 474 | - ); | |
| 475 | - assert!(encrypted.starts_with("enc:")); | |
| 476 | - | |
| 477 | - let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 478 | - unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) }; | |
| 479 | - let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed"); | |
| 480 | - unsafe { | |
| 481 | - match prev { | |
| 482 | - Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 483 | - None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 484 | - } | |
| 485 | - } | |
| 486 | - assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 487 | - JoanieBeta { | |
| 488 | - private_pem, | |
| 489 | - actor_url, | |
| 490 | - } | |
| 491 | -} | |
| 492 | - | |
| 493 | -/// Initiate an AuthRequest on B for the given handle (e.g. | |
| 494 | -/// `"joanie@alpha.local"`). Returns the B-local `challenge_id` and the | |
| 495 | -/// `nonce` that the cross-instance protocol uses as correlation token. | |
| 496 | -async fn initiate_on_beta(client: &Client, handle: &str) -> (Uuid, String) { | |
| 497 | - let body: Value = client | |
| 498 | - .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 499 | - .json(&serde_json::json!({ "handle": handle })) | |
| 500 | - .send() | |
| 501 | - .await | |
| 502 | - .expect("initiate request must complete") | |
| 503 | - .json() | |
| 504 | - .await | |
| 505 | - .expect("initiate response must be JSON"); | |
| 506 | - let id = Uuid::parse_str(body["challenge_id"].as_str().unwrap()).unwrap(); | |
| 507 | - let nonce = body["nonce"].as_str().unwrap().to_string(); | |
| 508 | - (id, nonce) | |
| 509 | -} | |
| 510 | - | |
| 511 | -/// Build, sign, and POST an `Accept(AuthRequest)` or | |
| 512 | -/// `Reject(AuthRequest)` from joanie@alpha to beta's inbox. Returns the | |
| 513 | -/// raw HTTP response so callers can assert on status + body. | |
| 514 | -async fn send_decision_to_beta( | |
| 515 | - client: &Client, | |
| 516 | - alpha: &JoanieBeta, | |
| 517 | - nonce: &str, | |
| 518 | - approve: bool, | |
| 519 | -) -> reqwest::Response { | |
| 520 | - let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 521 | - let payload = serde_json::json!({ | |
| 522 | - "@context": [ | |
| 523 | - "https://www.w3.org/ns/activitystreams", | |
| 524 | - { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } | |
| 525 | - ], | |
| 526 | - "type": activity_type, | |
| 527 | - "id": format!("http://alpha.local/auth-decisions/{}", uuid::Uuid::new_v4()), | |
| 528 | - "actor": alpha.actor_url, | |
| 529 | - "object": { "type": "AuthRequest", "nonce": nonce }, | |
| 530 | - "to": ["http://beta.local/users/joanie/inbox"], | |
| 531 | - }); | |
| 532 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 533 | - | |
| 534 | - let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 535 | - let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 536 | - | |
| 537 | - client | |
| 538 | - .post(target_url) | |
| 539 | - .header("Host", host) | |
| 540 | - .header("Date", date) | |
| 541 | - .header("Signature", sig) | |
| 542 | - .header("Content-Type", "application/activity+json") | |
| 543 | - .body(body_bytes) | |
| 544 | - .send() | |
| 545 | - .await | |
| 546 | - .expect("decision POST must complete") | |
| 547 | -} | |
| 548 | - | |
| 549 | -#[tokio::test] | |
| 550 | -async fn s1_full_round_trip_accept_marks_b_challenge_approved() { | |
| 551 | - let Some(_) = testbed_or_skip().await else { | |
| 552 | - return; | |
| 553 | - }; | |
| 554 | - let client = signing_client(); | |
| 555 | - | |
| 556 | - // 1. B initiates: creates a Pending local challenge. | |
| 557 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 558 | - | |
| 559 | - // 2. Alpha keys (the approver clicking Approve on the IdP-side UI). | |
| 560 | - let alpha = joanie_alpha_keys(&client).await; | |
| 561 | - | |
| 562 | - // 3. Send signed Accept to B's inbox. | |
| 563 | - let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ true).await; | |
| 564 | - let status = resp.status(); | |
| 565 | - let body = resp.text().await.unwrap_or_default(); | |
| 566 | - assert_eq!( | |
| 567 | - status, | |
| 568 | - StatusCode::ACCEPTED, | |
| 569 | - "beta must 202 a valid Accept; got {status} body={body}" | |
| 570 | - ); | |
| 571 | - | |
| 572 | - // 4. B's local challenge must now be Approved. | |
| 573 | - let row_status = psql( | |
| 574 | - "beta", | |
| 575 | - &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 576 | - ); | |
| 577 | - assert_eq!(row_status, "approved", "challenge status mismatch"); | |
| 578 | - | |
| 579 | - // 5. B's consume must mint a remote JWT. | |
| 580 | - let consume = client | |
| 581 | - .post(format!( | |
| 582 | - "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 583 | - )) | |
| 584 | - .send() | |
| 585 | - .await | |
| 586 | - .expect("consume must complete"); | |
| 587 | - assert_eq!(consume.status(), StatusCode::OK); | |
| 588 | - let consume_body: Value = consume.json().await.unwrap(); | |
| 589 | - assert!(consume_body["jwt"].as_str().is_some()); | |
| 590 | -} | |
| 591 | - | |
| 592 | -#[tokio::test] | |
| 593 | -async fn s1_reject_propagates_and_blocks_consume() { | |
| 594 | - let Some(_) = testbed_or_skip().await else { | |
| 595 | - return; | |
| 596 | - }; | |
| 597 | - let client = signing_client(); | |
| 598 | - | |
| 599 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 600 | - let alpha = joanie_alpha_keys(&client).await; | |
| 601 | - | |
| 602 | - let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ false).await; | |
| 603 | - assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 604 | - | |
| 605 | - let row_status = psql( | |
| 606 | - "beta", | |
| 607 | - &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 608 | - ); | |
| 609 | - assert_eq!(row_status, "rejected"); | |
| 610 | - | |
| 611 | - let consume = client | |
| 612 | - .post(format!( | |
| 613 | - "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 614 | - )) | |
| 615 | - .send() | |
| 616 | - .await | |
| 617 | - .expect("consume must complete"); | |
| 618 | - assert!( | |
| 619 | - consume.status().is_client_error(), | |
| 620 | - "rejected challenge must yield 4xx on consume, got {}", | |
| 621 | - consume.status() | |
| 622 | - ); | |
| 623 | -} | |
| 624 | - | |
| 625 | -#[tokio::test] | |
| 626 | -async fn s1_unknown_nonce_is_rejected() { | |
| 627 | - let Some(_) = testbed_or_skip().await else { | |
| 628 | - return; | |
| 629 | - }; | |
| 630 | - let client = signing_client(); | |
| 631 | - let alpha = joanie_alpha_keys(&client).await; | |
| 632 | - | |
| 633 | - // Forge a fresh nonce that no B-side initiate has used. | |
| 634 | - let fake_nonce = format!("never-seen-{}", uuid::Uuid::new_v4()); | |
| 635 | - let resp = send_decision_to_beta(&client, &alpha, &fake_nonce, true).await; | |
| 636 | - let status = resp.status(); | |
| 637 | - assert!( | |
| 638 | - status.is_client_error(), | |
| 639 | - "unknown nonce must be refused, got {status}" | |
| 640 | - ); | |
| 641 | -} | |
| 642 | - | |
| 643 | -#[tokio::test] | |
| 644 | -async fn s1_expired_challenge_rejects_late_accept() { | |
| 645 | - let Some(_) = testbed_or_skip().await else { | |
| 646 | - return; | |
| 647 | - }; | |
| 648 | - let client = signing_client(); | |
| 649 | - | |
| 650 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 651 | - let alpha = joanie_alpha_keys(&client).await; | |
| 652 | - | |
| 653 | - // Manually backdate `expires_at` so the challenge looks expired. | |
| 654 | - psql( | |
| 655 | - "beta", | |
| 656 | - &format!( | |
| 657 | - "UPDATE fediverse_auth_challenges SET expires_at = NOW() - INTERVAL '1 hour' \ | |
| 658 | - WHERE id='{challenge_id}'" | |
| 659 | - ), | |
| 660 | - ); | |
| 661 | - | |
| 662 | - let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 663 | - assert!( | |
| 664 | - resp.status().is_client_error(), | |
| 665 | - "expired challenge must reject Accept; got {}", | |
| 666 | - resp.status() | |
| 667 | - ); | |
| 668 | -} | |
| 669 | - | |
| 670 | -#[tokio::test] | |
| 671 | -async fn s1_replay_accept_is_idempotent() { | |
| 672 | - let Some(_) = testbed_or_skip().await else { | |
| 673 | - return; | |
| 674 | - }; | |
| 675 | - let client = signing_client(); | |
| 676 | - | |
| 677 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 678 | - let alpha = joanie_alpha_keys(&client).await; | |
| 679 | - | |
| 680 | - let r1 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 681 | - assert_eq!(r1.status(), StatusCode::ACCEPTED); | |
| 682 | - | |
| 683 | - // PostgreSQL system column `xmin` carries the transaction id that | |
| 684 | - // last wrote the row. If `record_inbound_decision` correctly bails | |
| 685 | - // out on a non-Pending challenge, xmin must be stable. | |
| 686 | - let xmin_1 = psql( | |
| 687 | - "beta", | |
| 688 | - &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 689 | - ); | |
| 690 | - | |
| 691 | - let r2 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 692 | - assert_eq!(r2.status(), StatusCode::ACCEPTED, "replay must still 202"); | |
| 693 | - | |
| 694 | - let xmin_2 = psql( | |
| 695 | - "beta", | |
| 696 | - &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 697 | - ); | |
| 698 | - assert_eq!(xmin_1, xmin_2, "idempotent replay must not rewrite the row"); | |
| 699 | -} | |
| 700 | - | |
| 701 | -// ─────────────────────────────────────────────────────────────────────── | |
| 702 | -// Iteration 2a (S2) — Cross-instance team invitation, B-side flow. | |
| 703 | -// | |
| 704 | -// Validates the part of S2 that is operational without the missing | |
| 705 | -// AP-Invite back-channel (findings F4–F7 of the iter1 plan, hors-scope | |
| 706 | -// this iteration). Specifically: bob (admin on B) creates a federated | |
| 707 | -// invite for @joanie@alpha.local; joanie already has a remote_identity | |
| 708 | -// row on B (populated by S1's verifier); accepting the invite via the | |
| 709 | -// API attaches a team_member row bound to that remote identity. | |
| 710 | -// ─────────────────────────────────────────────────────────────────────── | |
| 711 | - | |
| 712 | -/// Run a one-shot S1 happy-path so joanie@alpha is cached in beta's | |
| 713 | -/// `remote_identities` before the invite tests need her. | |
| 714 | -async fn ensure_joanie_alpha_in_beta_cache(client: &Client) { | |
| 715 | - let (_challenge_id, nonce) = initiate_on_beta(client, "joanie@alpha.local").await; | |
| 716 | - let alpha = joanie_alpha_keys(client).await; | |
| 717 | - let _ = send_decision_to_beta(client, &alpha, &nonce, true).await; | |
| 718 | -} | |
| 719 | - | |
| 720 | -#[tokio::test] | |
| 721 | -async fn s2_admin_invite_to_remote_joanie_creates_team_membership() { | |
| 722 | - let Some(_) = testbed_or_skip().await else { | |
| 723 | - return; | |
| 724 | - }; | |
| 725 | - let client = signing_client(); | |
| 726 | - | |
| 727 | - // 0. Make sure beta knows joanie@alpha (S1 contract). | |
| 728 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 729 | - let joanie_alpha_rid = psql( | |
| 730 | - "beta", | |
| 731 | - "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 732 | - ); | |
| 733 | - assert!(!joanie_alpha_rid.is_empty(), "joanie@alpha must be cached"); | |
| 734 | - | |
| 735 | - // 1. Create a team on beta. Slug must be unique across runs. | |
| 736 | - let team_slug = format!( | |
| 737 | - "team-s2-{}", | |
| 738 | - Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 739 | - ); | |
| 740 | - psql( | |
| 741 | - "beta", | |
| 742 | - &format!( | |
| 743 | - "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 744 | - VALUES (gen_random_uuid(), '{team_slug}', 'S2 Test Team', \ | |
| 745 | - (SELECT id FROM users WHERE username='joanie'))" | |
| 746 | - ), | |
| 747 | - ); | |
| 748 | - let team_id = psql( | |
| 749 | - "beta", | |
| 750 | - &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 751 | - ); | |
| 752 | - | |
| 753 | - // 2. Admin creates the invite via the API. | |
| 754 | - let invite_resp: Value = client | |
| 755 | - .post(format!( | |
| 756 | - "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 757 | - )) | |
| 758 | - .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 759 | - .send() | |
| 760 | - .await | |
| 761 | - .expect("invite POST must complete") | |
| 762 | - .json() | |
| 763 | - .await | |
| 764 | - .expect("invite response must be JSON"); | |
| 765 | - let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 766 | - | |
| 767 | - // 3. Accept the invite, binding to joanie@alpha's remote_identity. | |
| 768 | - let accept = client | |
| 769 | - .post(format!( | |
| 770 | - "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 771 | - )) | |
| 772 | - .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 773 | - .send() | |
| 774 | - .await | |
| 775 | - .expect("accept must complete"); | |
| 776 | - assert_eq!(accept.status(), StatusCode::OK, "accept must 200"); | |
| 777 | - | |
| 778 | - // 4. team_members row must exist with the remote_identity_id. | |
| 779 | - let member_count = psql( | |
| 780 | - "beta", | |
| 781 | - &format!( | |
| 782 | - "SELECT COUNT(*) FROM team_members \ | |
| 783 | - WHERE team_id='{team_id}' AND remote_identity_id='{joanie_alpha_rid}'" | |
| 784 | - ), | |
| 785 | - ); | |
| 786 | - assert_eq!(member_count, "1", "exactly 1 remote team_member expected"); | |
| 787 | - | |
| 788 | - // 5. Invite status must be `accepted`. | |
| 789 | - let invite_status = psql( | |
| 790 | - "beta", | |
| 791 | - &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 792 | - ); | |
| 793 | - assert_eq!(invite_status, "accepted"); | |
| 794 | -} | |
| 795 | - | |
| 796 | -#[tokio::test] | |
| 797 | -async fn s2_invite_reject_flips_status_to_rejected_no_member() { | |
| 798 | - let Some(_) = testbed_or_skip().await else { | |
| 799 | - return; | |
| 800 | - }; | |
| 801 | - let client = signing_client(); | |
| 802 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 803 | - | |
| 804 | - let team_slug = format!( | |
| 805 | - "team-s2-rej-{}", | |
| 806 | - Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 807 | - ); | |
| 808 | - psql( | |
| 809 | - "beta", | |
| 810 | - &format!( | |
| 811 | - "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 812 | - VALUES (gen_random_uuid(), '{team_slug}', 'Rej Team', \ | |
| 813 | - (SELECT id FROM users WHERE username='joanie'))" | |
| 814 | - ), | |
| 815 | - ); | |
| 816 | - let team_id = psql( | |
| 817 | - "beta", | |
| 818 | - &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 819 | - ); | |
| 820 | - | |
| 821 | - let invite_resp: Value = client | |
| 822 | - .post(format!( | |
| 823 | - "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 824 | - )) | |
| 825 | - .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 826 | - .send() | |
| 827 | - .await | |
| 828 | - .unwrap() | |
| 829 | - .json() | |
| 830 | - .await | |
| 831 | - .unwrap(); | |
| 832 | - let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 833 | - | |
| 834 | - let reject = client | |
| 835 | - .post(format!( | |
| 836 | - "{BETA}/api/v1/fediverse/invites/{invite_id}/reject" | |
| 837 | - )) | |
| 838 | - .send() | |
| 839 | - .await | |
| 840 | - .unwrap(); | |
| 841 | - assert_eq!(reject.status(), StatusCode::OK); | |
| 842 | - | |
| 843 | - let invite_status = psql( | |
| 844 | - "beta", | |
| 845 | - &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 846 | - ); | |
| 847 | - assert_eq!(invite_status, "rejected"); | |
| 848 | - | |
| 849 | - let member_count = psql( | |
| 850 | - "beta", | |
| 851 | - &format!("SELECT COUNT(*) FROM team_members WHERE team_id='{team_id}'"), | |
| 852 | - ); | |
| 853 | - assert_eq!(member_count, "0", "rejection must NOT add a member"); | |
| 854 | -} | |
| 855 | - | |
| 856 | -#[tokio::test] | |
| 857 | -async fn s2_invite_revoke_blocks_subsequent_accept() { | |
| 858 | - let Some(_) = testbed_or_skip().await else { | |
| 859 | - return; | |
| 860 | - }; | |
| 861 | - let client = signing_client(); | |
| 862 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 863 | - let joanie_alpha_rid = psql( | |
| 864 | - "beta", | |
| 865 | - "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 866 | - ); | |
| 867 | - | |
| 868 | - let team_slug = format!( | |
| 869 | - "team-s2-rev-{}", | |
| 870 | - Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 871 | - ); | |
| 872 | - psql( | |
| 873 | - "beta", | |
| 874 | - &format!( | |
| 875 | - "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 876 | - VALUES (gen_random_uuid(), '{team_slug}', 'Rev Team', \ | |
| 877 | - (SELECT id FROM users WHERE username='joanie'))" | |
| 878 | - ), | |
| 879 | - ); | |
| 880 | - let team_id = psql( | |
| 881 | - "beta", | |
| 882 | - &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 883 | - ); | |
| 884 | - | |
| 885 | - let invite_resp: Value = client | |
| 886 | - .post(format!( | |
| 887 | - "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 888 | - )) | |
| 889 | - .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 890 | - .send() | |
| 891 | - .await | |
| 892 | - .unwrap() | |
| 893 | - .json() | |
| 894 | - .await | |
| 895 | - .unwrap(); | |
| 896 | - let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 897 | - | |
| 898 | - // Admin revokes the invite before joanie accepts. | |
| 899 | - let revoke = client | |
| 900 | - .post(format!( | |
| 901 | - "{BETA}/api/v1/teams/{team_id}/invites/{invite_id}/revoke" | |
| 902 | - )) | |
| 903 | - .send() | |
| 904 | - .await | |
| 905 | - .unwrap(); | |
| 906 | - assert_eq!(revoke.status(), StatusCode::OK); | |
| 907 | - | |
| 908 | - // Joanie's late acceptance must now fail (state != Pending). | |
| 909 | - let accept = client | |
| 910 | - .post(format!( | |
| 911 | - "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 912 | - )) | |
| 913 | - .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 914 | - .send() | |
| 915 | - .await | |
| 916 | - .unwrap(); | |
| 917 | - assert!( | |
| 918 | - accept.status().is_client_error(), | |
| 919 | - "revoked invite must reject Accept, got {}", | |
| 920 | - accept.status() | |
| 921 | - ); | |
| 922 | -} | |
| 923 | - | |
| 924 | -#[tokio::test] | |
| 925 | -async fn s2_idp_list_returns_pending_invite_for_handle() { | |
| 926 | - let Some(_) = testbed_or_skip().await else { | |
| 927 | - return; | |
| 928 | - }; | |
| 929 | - let client = signing_client(); | |
| 930 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 931 | - | |
| 932 | - let team_slug = format!( | |
| 933 | - "team-s2-list-{}", | |
| 934 | - Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 935 | - ); | |
| 936 | - psql( | |
| 937 | - "beta", | |
| 938 | - &format!( | |
| 939 | - "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 940 | - VALUES (gen_random_uuid(), '{team_slug}', 'List Team', \ | |
| 941 | - (SELECT id FROM users WHERE username='joanie'))" | |
| 942 | - ), | |
| 943 | - ); | |
| 944 | - let team_id = psql( | |
| 945 | - "beta", | |
| 946 | - &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 947 | - ); | |
| 948 | - | |
| 949 | - let invite_resp: Value = client | |
| 950 | - .post(format!( | |
| 951 | - "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 952 | - )) | |
| 953 | - .json(&json!({ "handle": "joanie@alpha.local", "role": "admin" })) | |
| 954 | - .send() | |
| 955 | - .await | |
| 956 | - .unwrap() | |
| 957 | - .json() | |
| 958 | - .await | |
| 959 | - .unwrap(); | |
| 960 | - let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 961 | - | |
| 962 | - // IdP-side listing: beta's own /api/v1/fediverse/invites endpoint | |
| 963 | - // returns invites pending for a given handle. | |
| 964 | - let listed: Value = client | |
| 965 | - .get(format!("{BETA}/api/v1/fediverse/invites")) | |
| 966 | - .query(&[("handle", "joanie@alpha.local")]) | |
| 967 | - .send() | |
| 968 | - .await | |
| 969 | - .expect("list must complete") | |
| 970 | - .json() | |
| 971 | - .await | |
| 972 | - .expect("list response must be JSON"); | |
| 973 | - let arr = listed.as_array().expect("list must be array"); | |
| 974 | - let found = arr.iter().any(|row| row["id"].as_str() == Some(&invite_id)); | |
| 975 | - assert!(found, "freshly-created invite must appear in the listing"); | |
| 976 | -} | |
| 977 | - | |
| 978 | -// ─────────────────────────────────────────────────────────────────────── | |
| 979 | -// Iteration 3a (S3) — Trust model & non-transitivity | |
| 980 | -// | |
| 981 | -// Scenario from plan parent §S3 : | |
| 982 | -// A trust B uniquement | |
| 983 | -// B trust A and C | |
| 984 | -// C trust B uniquement | |
| 985 | -// A does NOT trust C, and C is not in A's blocklist either. | |
| 986 | -// | |
| 987 | -// Property under test : an AP activity signed by some domain D may | |
| 988 | -// only enrich A's protocol state in ways scoped to D itself — it MUST | |
| 989 | -// NOT cause A to add D (or anyone else) to `fediverse_trusted_domains` | |
| 990 | -// automatically. The trust list is **administrator-only** input. | |
| 991 | -// | |
| 992 | -// **F8 (bloquant pour promotion stable)** — the protocolar routes | |
| 993 | -// (auth_routes, inbox, invite_routes) never consult | |
| 994 | -// `FediverseTrustService`. Trust list enforcement is decorative | |
| 995 | -// (UI-only). A hostile but signature-valid instance can today push | |
| 996 | -// AP activities into another instance regardless of its trust state. | |
| 997 | -// The test `s3_finding_f8_untrusted_domain_payload_accepted` | |
| 998 | -// reproduces the bug objectively. | |
| 999 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1000 | - | |
| 1001 | -#[tokio::test] | |
| 1002 | -async fn s3_signed_protocol_flow_never_writes_trust_list() { | |
| 1003 | - let Some(_) = testbed_or_skip().await else { | |
| 1004 | - return; | |
| 1005 | - }; | |
| 1006 | - let client = signing_client(); | |
| 1007 | - | |
| 1008 | - // Snapshot beta's trust list before any protocol exchange. | |
| 1009 | - let before = psql( | |
| 1010 | - "beta", | |
| 1011 | - "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1012 | - ); | |
| 1013 | - | |
| 1014 | - // Drive a complete signed S1 flow (which exercises auth_inbox + | |
| 1015 | - // F2's fetch_and_cache_remote_actor + F3's record_inbound_decision) | |
| 1016 | - // and a signed S2 invite flow. | |
| 1017 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1018 | - let team_slug = format!( | |
| 1019 | - "team-s3-trust-{}", | |
| 1020 | - Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 1021 | - ); | |
| 1022 | - psql( | |
| 1023 | - "beta", | |
| 1024 | - &format!( | |
| 1025 | - "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 1026 | - VALUES (gen_random_uuid(), '{team_slug}', 'S3 Trust Team', \ | |
| 1027 | - (SELECT id FROM users WHERE username='joanie'))" | |
| 1028 | - ), | |
| 1029 | - ); | |
| 1030 | - let team_id = psql( | |
| 1031 | - "beta", | |
| 1032 | - &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 1033 | - ); | |
| 1034 | - let _ = client | |
| 1035 | - .post(format!( | |
| 1036 | - "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 1037 | - )) | |
| 1038 | - .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 1039 | - .send() | |
| 1040 | - .await | |
| 1041 | - .unwrap(); | |
| 1042 | - | |
| 1043 | - // After all that, the trust list must be byte-identical: protocol | |
| 1044 | - // exchanges do not promote anyone to trusted. | |
| 1045 | - let after = psql( | |
| 1046 | - "beta", | |
| 1047 | - "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1048 | - ); | |
| 1049 | - assert_eq!( | |
| 1050 | - before, after, | |
| 1051 | - "fediverse_trusted_domains must be untouched by protocol-level activity" | |
| 1052 | - ); | |
| 1053 | -} | |
| 1054 | - | |
| 1055 | -#[tokio::test] | |
| 1056 | -async fn s3_untrusted_domain_is_rejected_with_403() { | |
| 1057 | - // F8 fix : beta's auth_inbox now consults | |
| 1058 | - // FediverseTrustService::assert_domain_allowed and refuses 403 | |
| 1059 | - // (FediverseError::Forbidden) when the source domain is not in | |
| 1060 | - // beta's allowlist. | |
| 1061 | - | |
| 1062 | - let Some(_) = testbed_or_skip().await else { | |
| 1063 | - return; | |
| 1064 | - }; | |
| 1065 | - let client = signing_client(); | |
| 1066 | - | |
| 1067 | - // 1. Remove alpha from beta's trust list. | |
| 1068 | - psql( | |
| 1069 | - "beta", | |
| 1070 | - "DELETE FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1071 | - ); | |
| 1072 | - let trust_count = psql( | |
| 1073 | - "beta", | |
| 1074 | - "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1075 | - ); | |
| 1076 | - assert_eq!(trust_count, "0", "alpha must be removed from beta's trust list"); | |
| 1077 | - | |
| 1078 | - // 2. Sign and send an AuthRequest from joanie@alpha to beta. | |
| 1079 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1080 | - let nonce = Uuid::new_v4().to_string(); | |
| 1081 | - let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1082 | - let payload = json!({ | |
| 1083 | - "type": "AuthRequest", | |
| 1084 | - "actor": alpha.actor_url, | |
| 1085 | - "target": "joanie@beta.local", | |
| 1086 | - "nonce": nonce, | |
| 1087 | - "expiresAt": expires_at, | |
| 1088 | - }); | |
| 1089 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1090 | - let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1091 | - let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1092 | - | |
| 1093 | - let resp = client | |
| 1094 | - .post(target_url) | |
| 1095 | - .header("Host", host) | |
| 1096 | - .header("Date", date) | |
| 1097 | - .header("Signature", sig) | |
| 1098 | - .header("Content-Type", "application/activity+json") | |
| 1099 | - .body(body_bytes) | |
| 1100 | - .send() | |
| 1101 | - .await | |
| 1102 | - .expect("POST must complete"); | |
| 1103 | - | |
| 1104 | - let status = resp.status(); | |
| 1105 | - // Restore trust list FIRST to avoid poisoning other tests on failure path. | |
| 1106 | - psql( | |
| 1107 | - "beta", | |
| 1108 | - "INSERT INTO fediverse_trusted_domains (id, domain, note, created_at) \ | |
| 1109 | - VALUES (gen_random_uuid(), 'alpha.local', 'restore after S3 test', NOW()) \ | |
| 1110 | - ON CONFLICT (domain) DO NOTHING", | |
| 1111 | - ); | |
| 1112 | - | |
| 1113 | - assert_eq!( | |
| 1114 | - status, | |
| 1115 | - StatusCode::FORBIDDEN, | |
| 1116 | - "F8 enforcement: untrusted source must receive 403; got {status}" | |
| 1117 | - ); | |
| 1118 | -} | |
| 1119 | - | |
| 1120 | -#[tokio::test] | |
| 1121 | -async fn s3_trusted_domain_still_passes_protocol_inbox() { | |
| 1122 | - // Positive regression for F8 : while the trust list contains alpha | |
| 1123 | - // (default after setup.sh + configure-trust.sh), signed activities | |
| 1124 | - // from alpha must still 202. | |
| 1125 | - let Some(_) = testbed_or_skip().await else { | |
| 1126 | - return; | |
| 1127 | - }; | |
| 1128 | - let client = signing_client(); | |
| 1129 | - | |
| 1130 | - // Confirm alpha is in beta's trust list (configure-trust.sh state). | |
| 1131 | - let present = psql( | |
| 1132 | - "beta", | |
| 1133 | - "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1134 | - ); | |
| 1135 | - assert_eq!(present, "1", "alpha must be in beta's trust list"); | |
| 1136 | - | |
| 1137 | - let (_challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1138 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1139 | - let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1140 | - assert_eq!( | |
| 1141 | - resp.status(), | |
| 1142 | - StatusCode::ACCEPTED, | |
| 1143 | - "trusted domain must continue to be accepted" | |
| 1144 | - ); | |
| 1145 | -} | |
| 1146 | - | |
| 1147 | -#[tokio::test] | |
| 1148 | -async fn s3_trust_list_entries_persist_independent_of_protocol_activity() { | |
| 1149 | - let Some(_) = testbed_or_skip().await else { | |
| 1150 | - return; | |
| 1151 | - }; | |
| 1152 | - let client = signing_client(); | |
| 1153 | - | |
| 1154 | - // Both pre-existing trust entries are present (configure-trust.sh | |
| 1155 | - // bootstrap). Hammer the protocol with a signed flow and assert the | |
| 1156 | - // exact same rows remain — not merely a count check. | |
| 1157 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1158 | - | |
| 1159 | - let beta_trust = psql( | |
| 1160 | - "beta", | |
| 1161 | - "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1162 | - ); | |
| 1163 | - let gamma_trust = psql( | |
| 1164 | - "gamma", | |
| 1165 | - "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1166 | - ); | |
| 1167 | - assert_eq!( | |
| 1168 | - beta_trust, "alpha.local,gamma.local", | |
| 1169 | - "beta's trust list must remain alpha+gamma — no auto-mutation" | |
| 1170 | - ); | |
| 1171 | - assert_eq!( | |
| 1172 | - gamma_trust, "alpha.local,beta.local", | |
| 1173 | - "gamma's trust list must remain alpha+beta — non-transitivity preserved" | |
| 1174 | - ); | |
| 1175 | -} | |
| 1176 | - | |
| 1177 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1178 | -// Iteration 4 (S4) — Block list + révocation en masse | |
| 1179 | -// | |
| 1180 | -// Per plan §S4, admin must be able to "cut" a hostile instance with | |
| 1181 | -// immediate effect : future signed POSTs rejected AND existing remote | |
| 1182 | -// JWT sessions invalidated. | |
| 1183 | -// | |
| 1184 | -// Validation covers what works as-is via direct DB / API drive ; surfaces | |
| 1185 | -// **F9** (add_blocked_domain doesn't auto-kill remote_identities) and | |
| 1186 | -// **F10** (kill_domain doesn't blacklist JWT) as findings. | |
| 1187 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1188 | - | |
| 1189 | -/// Switch beta's trust mode to `open_with_blocklist` for the duration of | |
| 1190 | -/// a S4 test, returning the previous value so it can be restored. | |
| 1191 | -fn set_beta_open_with_blocklist() -> Option<String> { | |
| 1192 | - let previous = psql( | |
| 1193 | - "beta", | |
| 1194 | - "SELECT value FROM app_settings WHERE key='fediverse_trust_mode'", | |
| 1195 | - ); | |
| 1196 | - psql( | |
| 1197 | - "beta", | |
| 1198 | - "INSERT INTO app_settings (id, key, value, description, updated_at) \ | |
| 1199 | - VALUES (gen_random_uuid(), 'fediverse_trust_mode', 'open_with_blocklist', NULL, NOW()) \ | |
| 1200 | - ON CONFLICT (key) DO UPDATE SET value='open_with_blocklist', updated_at=NOW()", | |
| 1201 | - ); | |
| 1202 | - if previous.is_empty() { None } else { Some(previous) } | |
| 1203 | -} | |
| 1204 | - | |
| 1205 | -fn restore_beta_trust_mode(previous: Option<String>) { | |
| 1206 | - let value = previous.unwrap_or_else(|| "allowlist".to_string()); | |
| 1207 | - psql( | |
| 1208 | - "beta", | |
| 1209 | - &format!( | |
| 1210 | - "UPDATE app_settings SET value='{value}', updated_at=NOW() \ | |
| 1211 | - WHERE key='fediverse_trust_mode'" | |
| 1212 | - ), | |
| 1213 | - ); | |
| 1214 | -} | |
| 1215 | - | |
| 1216 | -#[tokio::test] | |
| 1217 | -async fn s4_blocked_domain_payload_is_rejected_with_403() { | |
| 1218 | - let Some(_) = testbed_or_skip().await else { | |
| 1219 | - return; | |
| 1220 | - }; | |
| 1221 | - let client = signing_client(); | |
| 1222 | - | |
| 1223 | - let prev = set_beta_open_with_blocklist(); | |
| 1224 | - // Add alpha.local to beta's blocklist. | |
| 1225 | - psql( | |
| 1226 | - "beta", | |
| 1227 | - "INSERT INTO fediverse_blocked_domains \ | |
| 1228 | - (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1229 | - VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 's4-test', NULL, NOW()) \ | |
| 1230 | - ON CONFLICT (domain) DO NOTHING", | |
| 1231 | - ); | |
| 1232 | - | |
| 1233 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1234 | - let nonce = Uuid::new_v4().to_string(); | |
| 1235 | - let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1236 | - let payload = json!({ | |
| 1237 | - "type": "AuthRequest", | |
| 1238 | - "actor": alpha.actor_url, | |
| 1239 | - "target": "joanie@beta.local", | |
| 1240 | - "nonce": nonce, | |
| 1241 | - "expiresAt": expires_at, | |
| 1242 | - }); | |
| 1243 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1244 | - let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1245 | - let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1246 | - | |
| 1247 | - let resp = client | |
| 1248 | - .post(target_url) | |
| 1249 | - .header("Host", host) | |
| 1250 | - .header("Date", date) | |
| 1251 | - .header("Signature", sig) | |
| 1252 | - .header("Content-Type", "application/activity+json") | |
| 1253 | - .body(body_bytes) | |
| 1254 | - .send() | |
| 1255 | - .await | |
| 1256 | - .expect("POST must complete"); | |
| 1257 | - | |
| 1258 | - let status = resp.status(); | |
| 1259 | - | |
| 1260 | - // Clean up state regardless of assertion outcome. | |
| 1261 | - psql( | |
| 1262 | - "beta", | |
| 1263 | - "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'", | |
| 1264 | - ); | |
| 1265 | - restore_beta_trust_mode(prev); | |
| 1266 | - | |
| 1267 | - assert_eq!( | |
| 1268 | - status, | |
| 1269 | - StatusCode::FORBIDDEN, | |
| 1270 | - "blocklist must produce 403 in OpenWithBlocklist mode; got {status}" | |
| 1271 | - ); | |
| 1272 | -} | |
| 1273 | - | |
| 1274 | -#[tokio::test] | |
| 1275 | -async fn s4_kill_domain_marks_remote_identities_disabled() { | |
| 1276 | - let Some(_) = testbed_or_skip().await else { | |
| 1277 | - return; | |
| 1278 | - }; | |
| 1279 | - let client = signing_client(); | |
| 1280 | - | |
| 1281 | - // Ensure joanie@alpha exists in beta's cache (S1 flow seeds it). | |
| 1282 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1283 | - let active_before = psql( | |
| 1284 | - "beta", | |
| 1285 | - "SELECT COUNT(*) FROM remote_identities \ | |
| 1286 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1287 | - ); | |
| 1288 | - assert!( | |
| 1289 | - active_before.parse::<i64>().unwrap() >= 1, | |
| 1290 | - "expected at least one active row to kill" | |
| 1291 | - ); | |
| 1292 | - | |
| 1293 | - // Bulk disable. | |
| 1294 | - psql( | |
| 1295 | - "beta", | |
| 1296 | - "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1297 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1298 | - ); | |
| 1299 | - | |
| 1300 | - let active_after = psql( | |
| 1301 | - "beta", | |
| 1302 | - "SELECT COUNT(*) FROM remote_identities \ | |
| 1303 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1304 | - ); | |
| 1305 | - assert_eq!(active_after, "0", "no active row must remain for alpha.local"); | |
| 1306 | - | |
| 1307 | - // Restore for follow-on tests. | |
| 1308 | - psql( | |
| 1309 | - "beta", | |
| 1310 | - "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1311 | - WHERE domain='alpha.local'", | |
| 1312 | - ); | |
| 1313 | -} | |
| 1314 | - | |
| 1315 | -#[tokio::test] | |
| 1316 | -async fn s4_kill_domain_invalidates_active_remote_jwt() { | |
| 1317 | - // F10 fix : remote JWTs are rejected at validation time when their | |
| 1318 | - // backing `remote_identities` row was disabled (kill_domain, F9 | |
| 1319 | - // auto-kill, or revoke). No `jwt_blacklist` insertion needed — the | |
| 1320 | - // disabled_at check is a cheap PK lookup in `AuthService`. | |
| 1321 | - | |
| 1322 | - let Some(_) = testbed_or_skip().await else { | |
| 1323 | - return; | |
| 1324 | - }; | |
| 1325 | - let client = signing_client(); | |
| 1326 | - | |
| 1327 | - // 1. Full S1 round-trip → fresh remote JWT for joanie@alpha on beta. | |
| 1328 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1329 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1330 | - let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1331 | - let consume = client | |
| 1332 | - .post(format!( | |
| 1333 | - "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1334 | - )) | |
| 1335 | - .send() | |
| 1336 | - .await | |
| 1337 | - .unwrap(); | |
| 1338 | - assert_eq!(consume.status(), StatusCode::OK); | |
| 1339 | - let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1340 | - .as_str() | |
| 1341 | - .unwrap() | |
| 1342 | - .to_string(); | |
| 1343 | - | |
| 1344 | - // 2. Use the JWT to access a protected endpoint BEFORE kill_domain: | |
| 1345 | - // must redirect to /home (via auth middleware passing through). | |
| 1346 | - // We hit /profile which requires auth. | |
| 1347 | - let no_redirect_client = reqwest::Client::builder() | |
| 1348 | - .redirect(reqwest::redirect::Policy::none()) | |
| 1349 | - .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1350 | - .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1351 | - .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1352 | - .timeout(std::time::Duration::from_secs(5)) | |
| 1353 | - .build() | |
| 1354 | - .unwrap(); | |
| 1355 | - let before = no_redirect_client | |
| 1356 | - .get("http://beta.local/profile/connected_apps") | |
| 1357 | - .header("Cookie", format!("jwt_token={jwt}")) | |
| 1358 | - .send() | |
| 1359 | - .await | |
| 1360 | - .unwrap(); | |
| 1361 | - let before_location = before | |
| 1362 | - .headers() | |
| 1363 | - .get("location") | |
| 1364 | - .and_then(|v| v.to_str().ok()) | |
| 1365 | - .map(str::to_string); | |
| 1366 | - let before_redirects_login = before_location | |
| 1367 | - .as_deref() | |
| 1368 | - .is_some_and(|s| s.contains("/login")); | |
| 1369 | - // Pre-kill: middleware must NOT redirect to /login. It may render | |
| 1370 | - // /profile (200) or redirect within the app (e.g. /home) — anything | |
| 1371 | - // that's not "kicked back to /login" is acceptable. | |
| 1372 | - assert!( | |
| 1373 | - !before_redirects_login, | |
| 1374 | - "pre-kill response should NOT be redirected to /login; got status {} location {:?}", | |
| 1375 | - before.status(), | |
| 1376 | - before_location | |
| 1377 | - ); | |
| 1378 | - | |
| 1379 | - // 3. Kill alpha.local on beta. | |
| 1380 | - psql( | |
| 1381 | - "beta", | |
| 1382 | - "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1383 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1384 | - ); | |
| 1385 | - // Sanity check — disabled_at must really be set. | |
| 1386 | - let disabled_count = psql( | |
| 1387 | - "beta", | |
| 1388 | - "SELECT COUNT(*) FROM remote_identities \ | |
| 1389 | - WHERE domain='alpha.local' AND disabled_at IS NOT NULL", | |
| 1390 | - ); | |
| 1391 | - assert!( | |
| 1392 | - disabled_count.parse::<i64>().unwrap() >= 1, | |
| 1393 | - "expected at least one disabled row, got {disabled_count}" | |
| 1394 | - ); | |
| 1395 | - | |
| 1396 | - // 4. Same request, post-kill: the JWT must now be rejected. | |
| 1397 | - let after = no_redirect_client | |
| 1398 | - .get("http://beta.local/profile/connected_apps") | |
| 1399 | - .header("Cookie", format!("jwt_token={jwt}")) | |
| 1400 | - .send() | |
| 1401 | - .await | |
| 1402 | - .unwrap(); | |
| 1403 | - let after_location = after | |
| 1404 | - .headers() | |
| 1405 | - .get("location") | |
| 1406 | - .and_then(|v| v.to_str().ok()) | |
| 1407 | - .map(str::to_string); | |
| 1408 | - | |
| 1409 | - // Restore state so subsequent tests aren't poisoned. | |
| 1410 | - psql( | |
| 1411 | - "beta", | |
| 1412 | - "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1413 | - WHERE domain='alpha.local'", | |
| 1414 | - ); | |
| 1415 | - | |
| 1416 | - let redirected_to_login = after_location | |
| 1417 | - .as_deref() | |
| 1418 | - .is_some_and(|s| s.contains("/login")); | |
| 1419 | - assert!( | |
| 1420 | - after.status().is_redirection() && redirected_to_login, | |
| 1421 | - "post-kill request must redirect to /login; got status {} location {:?}", | |
| 1422 | - after.status(), | |
| 1423 | - after_location | |
| 1424 | - ); | |
| 1425 | -} | |
| 1426 | - | |
| 1427 | -#[tokio::test] | |
| 1428 | -async fn s4_add_blocked_domain_auto_kills_remote_identities() { | |
| 1429 | - // F9 fix : adding a domain to the blocklist (via the admin handler) | |
| 1430 | - // must atomically also disable all matching `remote_identities`. | |
| 1431 | - // Drives the admin endpoint via a direct service-layer simulation | |
| 1432 | - // (this test exercises the DB consequences ; HTTP-level admin auth | |
| 1433 | - // requires a logged-in admin session which is out of E2E scope). | |
| 1434 | - | |
| 1435 | - let Some(_) = testbed_or_skip().await else { | |
| 1436 | - return; | |
| 1437 | - }; | |
| 1438 | - let client = signing_client(); | |
| 1439 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1440 | - | |
| 1441 | - let active_before = psql( | |
| 1442 | - "beta", | |
| 1443 | - "SELECT COUNT(*) FROM remote_identities \ | |
| 1444 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1445 | - ); | |
| 1446 | - assert!( | |
| 1447 | - active_before.parse::<i64>().unwrap() >= 1, | |
| 1448 | - "S4 setup: at least 1 active alpha.local identity expected" | |
| 1449 | - ); | |
| 1450 | - | |
| 1451 | - // Simulate what the admin handler now does atomically: | |
| 1452 | - // INSERT blocked_domains + UPDATE remote_identities disabled_at. | |
| 1453 | - let prev = set_beta_open_with_blocklist(); | |
| 1454 | - psql( | |
| 1455 | - "beta", | |
| 1456 | - "INSERT INTO fediverse_blocked_domains \ | |
| 1457 | - (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1458 | - VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 'f9-test', NULL, NOW()) \ | |
| 1459 | - ON CONFLICT (domain) DO NOTHING; \ | |
| 1460 | - UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1461 | - WHERE domain='alpha.local' AND disabled_at IS NULL;", | |
| 1462 | - ); | |
| 1463 | - | |
| 1464 | - let active_after = psql( | |
| 1465 | - "beta", | |
| 1466 | - "SELECT COUNT(*) FROM remote_identities \ | |
| 1467 | - WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1468 | - ); | |
| 1469 | - | |
| 1470 | - // Restore state for downstream tests. | |
| 1471 | - psql( | |
| 1472 | - "beta", | |
| 1473 | - "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'; \ | |
| 1474 | - UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1475 | - WHERE domain='alpha.local';", | |
| 1476 | - ); | |
| 1477 | - restore_beta_trust_mode(prev); | |
| 1478 | - | |
| 1479 | - assert_eq!( | |
| 1480 | - active_after, "0", | |
| 1481 | - "after add_blocked_domain (F9), no active remote_identity for that domain should remain" | |
| 1482 | - ); | |
| 1483 | -} | |
| 1484 | - | |
| 1485 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1486 | -// Iteration 5 (S5) — Failure modes / negative scenarios | |
| 1487 | -// | |
| 1488 | -// Covers explicit failure paths from the parent plan §S5 : | |
| 1489 | -// - AuthRequest replay (same nonce twice → second rejected) | |
| 1490 | -// - WebFinger 404 for unknown user | |
| 1491 | -// - Initiate rejects malformed handle | |
| 1492 | -// - Date skew on signed request (finding F11: AlwaysAcceptVerifier | |
| 1493 | -// doesn't enforce Date window, only HttpSigVerifier does) | |
| 1494 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1495 | - | |
| 1496 | -#[tokio::test] | |
| 1497 | -async fn s5_replay_authrequest_with_same_nonce_rejected() { | |
| 1498 | - // `fediverse_auth_challenges.nonce` has a UNIQUE constraint; replaying | |
| 1499 | - // the same signed AuthRequest must fail (DB conflict → 5xx via | |
| 1500 | - // AppError::Internal). The protocol still rejects, satisfying the | |
| 1501 | - // anti-replay requirement of plan §S5. | |
| 1502 | - let Some(_) = testbed_or_skip().await else { | |
| 1503 | - return; | |
| 1504 | - }; | |
| 1505 | - let client = signing_client(); | |
| 1506 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1507 | - | |
| 1508 | - let nonce = Uuid::new_v4().to_string(); | |
| 1509 | - let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1510 | - let payload = json!({ | |
| 1511 | - "type": "AuthRequest", | |
| 1512 | - "actor": alpha.actor_url, | |
| 1513 | - "target": "joanie@beta.local", | |
| 1514 | - "nonce": nonce, | |
| 1515 | - "expiresAt": expires_at, | |
| 1516 | - }); | |
| 1517 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1518 | - let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1519 | - | |
| 1520 | - // 1st POST — valid. | |
| 1521 | - let (date1, host1, sig1) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1522 | - let r1 = client | |
| 1523 | - .post(target_url) | |
| 1524 | - .header("Host", host1) | |
| 1525 | - .header("Date", date1) | |
| 1526 | - .header("Signature", sig1) | |
| 1527 | - .header("Content-Type", "application/activity+json") | |
| 1528 | - .body(body_bytes.clone()) | |
| 1529 | - .send() | |
| 1530 | - .await | |
| 1531 | - .expect("1st POST must complete"); | |
| 1532 | - assert_eq!(r1.status(), StatusCode::ACCEPTED, "first POST must 202"); | |
| 1533 | - | |
| 1534 | - // 2nd POST — same nonce, fresh date/signature. | |
| 1535 | - let (date2, host2, sig2) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1536 | - let r2 = client | |
| 1537 | - .post(target_url) | |
| 1538 | - .header("Host", host2) | |
| 1539 | - .header("Date", date2) | |
| 1540 | - .header("Signature", sig2) | |
| 1541 | - .header("Content-Type", "application/activity+json") | |
| 1542 | - .body(body_bytes) | |
| 1543 | - .send() | |
| 1544 | - .await | |
| 1545 | - .expect("2nd POST must complete"); | |
| 1546 | - assert!( | |
| 1547 | - !r2.status().is_success(), | |
| 1548 | - "replayed AuthRequest must NOT succeed; got {}", | |
| 1549 | - r2.status() | |
| 1550 | - ); | |
| 1551 | -} | |
| 1552 | - | |
| 1553 | -#[tokio::test] | |
| 1554 | -async fn s5_webfinger_returns_404_for_unknown_user() { | |
| 1555 | - let Some(client) = testbed_or_skip().await else { | |
| 1556 | - return; | |
| 1557 | - }; | |
| 1558 | - let response = client | |
| 1559 | - .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 1560 | - .query(&[("resource", "acct:ghost-doesnt-exist@alpha.local")]) | |
| 1561 | - .send() | |
| 1562 | - .await | |
| 1563 | - .expect("webfinger request must complete"); | |
| 1564 | - assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 1565 | -} | |
| 1566 | - | |
| 1567 | -#[tokio::test] | |
| 1568 | -async fn s5_initiate_rejects_malformed_handle() { | |
| 1569 | - let Some(client) = testbed_or_skip().await else { | |
| 1570 | - return; | |
| 1571 | - }; | |
| 1572 | - // Handle missing @ → service Validation → 4xx (typically 400). | |
| 1573 | - let response = client | |
| 1574 | - .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1575 | - .json(&json!({ "handle": "not-a-handle-no-at-sign" })) | |
| 1576 | - .send() | |
| 1577 | - .await | |
| 1578 | - .expect("initiate must complete"); | |
| 1579 | - assert!( | |
| 1580 | - response.status().is_client_error(), | |
| 1581 | - "malformed handle must yield 4xx, got {}", | |
| 1582 | - response.status() | |
| 1583 | - ); | |
| 1584 | -} | |
| 1585 | - | |
| 1586 | -#[tokio::test] | |
| 1587 | -async fn s5_finding_f11_clock_skew_not_enforced_with_debug_verifier() { | |
| 1588 | - // Plan §S5 expects: signed request with Date header from 1+ hour ago | |
| 1589 | - // must be rejected. With AlwaysAcceptVerifier (testbed-default), no | |
| 1590 | - // Date enforcement is done — only HttpSigVerifier (production) | |
| 1591 | - // checks the window. This test documents the gap (F11) ; when the | |
| 1592 | - // testbed is reconfigured to use HttpSigVerifier (future RFC), flip | |
| 1593 | - // this assertion to expect 401. | |
| 1594 | - | |
| 1595 | - let Some(_) = testbed_or_skip().await else { | |
| 1596 | - return; | |
| 1597 | - }; | |
| 1598 | - let client = signing_client(); | |
| 1599 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1600 | - | |
| 1601 | - let nonce = Uuid::new_v4().to_string(); | |
| 1602 | - let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1603 | - let payload = json!({ | |
| 1604 | - "type": "AuthRequest", | |
| 1605 | - "actor": alpha.actor_url, | |
| 1606 | - "target": "joanie@beta.local", | |
| 1607 | - "nonce": nonce, | |
| 1608 | - "expiresAt": expires_at, | |
| 1609 | - }); | |
| 1610 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1611 | - let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1612 | - | |
| 1613 | - // Sign with a Date header that's 2 hours stale. The signature itself | |
| 1614 | - // is correctly computed over the stale Date, so signature validation | |
| 1615 | - // would still verify if performed — but the freshness check is what | |
| 1616 | - // should reject. | |
| 1617 | - let stale_date = (chrono::Utc::now() - chrono::Duration::hours(2)) | |
| 1618 | - .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1619 | - .to_string(); | |
| 1620 | - use base64::Engine; | |
| 1621 | - use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 1622 | - let signed = vec![ | |
| 1623 | - "(request-target)".to_string(), | |
| 1624 | - "host".to_string(), | |
| 1625 | - "date".to_string(), | |
| 1626 | - ]; | |
| 1627 | - let request_target = "post /fediverse/auth/inbox".to_string(); | |
| 1628 | - let host = "beta.local".to_string(); | |
| 1629 | - let date_for_input = stale_date.clone(); | |
| 1630 | - let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 1631 | - "host" => Some(host.clone()), | |
| 1632 | - "date" => Some(date_for_input.clone()), | |
| 1633 | - _ => None, | |
| 1634 | - }) | |
| 1635 | - .unwrap(); | |
| 1636 | - let sig_bytes = rsa_sha256_sign(&alpha.private_pem, input.as_bytes()).unwrap(); | |
| 1637 | - let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 1638 | - let sig_header = format!( | |
| 1639 | - r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#, | |
| 1640 | - alpha.actor_url | |
| 1641 | - ); | |
| 1642 | - | |
| 1643 | - let resp = client | |
| 1644 | - .post(target_url) | |
| 1645 | - .header("Host", host) | |
| 1646 | - .header("Date", stale_date) | |
| 1647 | - .header("Signature", sig_header) | |
| 1648 | - .header("Content-Type", "application/activity+json") | |
| 1649 | - .body(body_bytes) | |
| 1650 | - .send() | |
| 1651 | - .await | |
| 1652 | - .expect("POST must complete"); | |
| 1653 | - | |
| 1654 | - // F11 STATUS UPDATE: with HttpSigVerifier replacing AlwaysAcceptVerifier | |
| 1655 | - // in the testbed, this should become 401. Current: 202 accepts the | |
| 1656 | - // stale Date (only signature is checked, not freshness). | |
| 1657 | - assert_eq!( | |
| 1658 | - resp.status(), | |
| 1659 | - StatusCode::ACCEPTED, | |
| 1660 | - "F11 STATUS UPDATE: if this is now 401, real verifier is in use" | |
| 1661 | - ); | |
| 1662 | -} | |
| 1663 | - | |
| 1664 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1665 | -// Iteration 6 (S6) — Lifecycle Update / Delete Person activities | |
| 1666 | -// | |
| 1667 | -// AP convention: when alpha's user updates her profile, alpha pushes | |
| 1668 | -// `Update(Person)` to beta's `/inbox`. Beta's `dispatch_update` | |
| 1669 | -// refreshes the cached `remote_identities` row. Similarly, | |
| 1670 | -// `Delete(Person)` disables the row — combined with F10, any active | |
| 1671 | -// remote JWT for that user is immediately invalid. | |
| 1672 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1673 | - | |
| 1674 | -#[tokio::test] | |
| 1675 | -async fn s6_update_person_refreshes_cached_display_name() { | |
| 1676 | - let Some(_) = testbed_or_skip().await else { | |
| 1677 | - return; | |
| 1678 | - }; | |
| 1679 | - let client = signing_client(); | |
| 1680 | - | |
| 1681 | - // Make sure beta has joanie@alpha cached (F2 auto-fetch via verifier). | |
| 1682 | - ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1683 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1684 | - | |
| 1685 | - let new_name = format!("Joanie-Updated-{}", &Uuid::new_v4().to_string()[..8]); | |
| 1686 | - | |
| 1687 | - let payload = json!({ | |
| 1688 | - "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1689 | - "type": "Update", | |
| 1690 | - "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1691 | - "actor": alpha.actor_url, | |
| 1692 | - "object": { | |
| 1693 | - "id": alpha.actor_url, | |
| 1694 | - "type": "Person", | |
| 1695 | - "name": new_name, | |
| 1696 | - "preferredUsername": "joanie", | |
| 1697 | - }, | |
| 1698 | - }); | |
| 1699 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1700 | - let target_url = "http://beta.local/inbox"; | |
| 1701 | - let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1702 | - | |
| 1703 | - let resp = client | |
| 1704 | - .post(target_url) | |
| 1705 | - .header("Host", host) | |
| 1706 | - .header("Date", date) | |
| 1707 | - .header("Signature", sig) | |
| 1708 | - .header("Content-Type", "application/activity+json") | |
| 1709 | - .body(body_bytes) | |
| 1710 | - .send() | |
| 1711 | - .await | |
| 1712 | - .expect("Update POST must complete"); | |
| 1713 | - assert_eq!(resp.status(), StatusCode::ACCEPTED, "Update(Person) must 202"); | |
| 1714 | - | |
| 1715 | - let display = psql( | |
| 1716 | - "beta", | |
| 1717 | - &format!( | |
| 1718 | - "SELECT display_name FROM remote_identities WHERE ap_id='{}'", | |
| 1719 | - alpha.actor_url | |
| 1720 | - ), | |
| 1721 | - ); | |
| 1722 | - assert_eq!(display, new_name, "display_name must be refreshed on beta"); | |
| 1723 | -} | |
| 1724 | - | |
| 1725 | -#[tokio::test] | |
| 1726 | -async fn s6_delete_person_disables_remote_identity_and_kills_active_jwt() { | |
| 1727 | - let Some(_) = testbed_or_skip().await else { | |
| 1728 | - return; | |
| 1729 | - }; | |
| 1730 | - let client = signing_client(); | |
| 1731 | - | |
| 1732 | - // 1. Full S1 round-trip → minted remote JWT. | |
| 1733 | - let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1734 | - let alpha = joanie_alpha_keys(&client).await; | |
| 1735 | - let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1736 | - let consume = client | |
| 1737 | - .post(format!( | |
| 1738 | - "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1739 | - )) | |
| 1740 | - .send() | |
| 1741 | - .await | |
| 1742 | - .unwrap(); | |
| 1743 | - assert_eq!(consume.status(), StatusCode::OK); | |
| 1744 | - let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1745 | - .as_str() | |
| 1746 | - .unwrap() | |
| 1747 | - .to_string(); | |
| 1748 | - | |
| 1749 | - // 2. Alpha pushes Delete(Person) to beta's inbox. | |
| 1750 | - let payload = json!({ | |
| 1751 | - "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1752 | - "type": "Delete", | |
| 1753 | - "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1754 | - "actor": alpha.actor_url, | |
| 1755 | - "object": { "id": alpha.actor_url, "type": "Person" }, | |
| 1756 | - }); | |
| 1757 | - let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1758 | - let target_url = "http://beta.local/inbox"; | |
| 1759 | - let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1760 | - let resp = client | |
| 1761 | - .post(target_url) | |
| 1762 | - .header("Host", host) | |
| 1763 | - .header("Date", date) | |
| 1764 | - .header("Signature", sig) | |
| 1765 | - .header("Content-Type", "application/activity+json") | |
| 1766 | - .body(body_bytes) | |
| 1767 | - .send() | |
| 1768 | - .await | |
| 1769 | - .expect("Delete POST must complete"); | |
| 1770 | - assert_eq!(resp.status(), StatusCode::ACCEPTED, "Delete(Person) must 202"); | |
| 1771 | - | |
| 1772 | - // 3. remote_identity row must be disabled (apply_delete_actor sets disabled_at). | |
| 1773 | - let disabled = psql( | |
| 1774 | - "beta", | |
| 1775 | - &format!( | |
| 1776 | - "SELECT disabled_at IS NOT NULL FROM remote_identities WHERE ap_id='{}'", | |
| 1777 | - alpha.actor_url | |
| 1778 | - ), | |
| 1779 | - ); | |
| 1780 | - assert_eq!(disabled, "t", "remote_identity must be disabled"); | |
| 1781 | - | |
| 1782 | - // 4. With F10 in effect, the previously-minted JWT must now be | |
| 1783 | - // rejected at the next protected request. | |
| 1784 | - let no_redirect_client = reqwest::Client::builder() | |
| 1785 | - .redirect(reqwest::redirect::Policy::none()) | |
| 1786 | - .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1787 | - .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1788 | - .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1789 | - .timeout(std::time::Duration::from_secs(5)) | |
| 1790 | - .build() | |
| 1791 | - .unwrap(); | |
| 1792 | - let resp = no_redirect_client | |
| 1793 | - .get("http://beta.local/profile/connected_apps") | |
| 1794 | - .header("Cookie", format!("jwt_token={jwt}")) | |
| 1795 | - .send() | |
| 1796 | - .await | |
| 1797 | - .unwrap(); | |
| 1798 | - let location = resp | |
| 1799 | - .headers() | |
| 1800 | - .get("location") | |
| 1801 | - .and_then(|v| v.to_str().ok()) | |
| 1802 | - .map(str::to_string); | |
| 1803 | - | |
| 1804 | - // Restore so subsequent tests see a clean slate. | |
| 1805 | - psql( | |
| 1806 | - "beta", | |
| 1807 | - &format!( | |
| 1808 | - "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1809 | - WHERE ap_id='{}'", | |
| 1810 | - alpha.actor_url | |
| 1811 | - ), | |
| 1812 | - ); | |
| 1813 | - | |
| 1814 | - let redirected_login = location.as_deref().is_some_and(|s| s.contains("/login")); | |
| 1815 | - assert!( | |
| 1816 | - resp.status().is_redirection() && redirected_login, | |
| 1817 | - "JWT must be invalidated after Delete(Person); got status {} location {:?}", | |
| 1818 | - resp.status(), | |
| 1819 | - location | |
| 1820 | - ); | |
| 1821 | -} | |
| 1822 | - | |
| 1823 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1824 | -// Iteration 1c: S1 — consume mints a remote JWT when a B-side challenge | |
| 1825 | -// reaches the `approved` state. | |
| 1826 | -// | |
| 1827 | -// MAJOR PROTOCOL FINDING from this campaign (testbed iteration 1c): | |
| 1828 | -// the codebase has NO mechanism for propagating an approval from the | |
| 1829 | -// IdP (A) back to the SP (B). A's `consent` API flips the challenge on | |
| 1830 | -// A's side; B's `consume` reads its OWN local challenge; nothing connects | |
| 1831 | -// the two. There is no `AuthApproval` activity in the inbox handler. | |
| 1832 | -// | |
| 1833 | -// Conclusion: the full S1 SSO bilateral flow CANNOT round-trip with the | |
| 1834 | -// current code. This test therefore validates only the B-side half: | |
| 1835 | -// given a challenge already marked `approved` (which we simulate via | |
| 1836 | -// DB-direct UPDATE to stand in for the missing back-channel), the | |
| 1837 | -// `consume` endpoint must mint a valid JWT carrying the remote identity. | |
| 1838 | -// | |
| 1839 | -// Closing this gap requires an RFC and is OUT OF SCOPE for the campaign | |
| 1840 | -// quality-gate; it is reported as a blocker for promotion to stable. | |
| 1841 | -// ─────────────────────────────────────────────────────────────────────── | |
| 1842 | - | |
| 1843 | -#[tokio::test] | |
| 1844 | -async fn s1_consume_on_beta_mints_jwt_when_challenge_approved() { | |
| 1845 | - let Some(_) = testbed_or_skip().await else { | |
| 1846 | - return; | |
| 1847 | - }; | |
| 1848 | - let client = signing_client(); | |
| 1849 | - | |
| 1850 | - // 1. Initiate on B against the handle `joanie@alpha.local` — creates a | |
| 1851 | - // real Pending challenge on B so the test exercises the normal entry | |
| 1852 | - // point, not a synthetic row. | |
| 1853 | - let initiate: Value = client | |
| 1854 | - .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1855 | - .json(&json!({ "handle": "joanie@alpha.local" })) | |
| 1856 | - .send() | |
| 1857 | - .await | |
| 1858 | - .expect("initiate must succeed") | |
| 1859 | - .json() | |
| 1860 | - .await | |
| 1861 | - .expect("initiate body must be JSON"); | |
| 1862 | - let challenge_id = initiate["challenge_id"] | |
| 1863 | - .as_str() | |
| 1864 | - .expect("challenge_id missing") | |
| 1865 | - .to_string(); | |
| 1866 | - | |
| 1867 | - // 2. Seed B with a remote_identity for joanie@alpha. We need joanie@alpha's | |
| 1868 | - // public PEM (which alpha has generated for any actor fetch we do here). | |
| 1869 | - let alpha_actor: Value = client | |
| 1870 | - .get(format!("{ALPHA}/users/joanie")) | |
| 1871 | - .header("Accept", "application/activity+json") | |
| 1872 | - .send() | |
| 1873 | - .await | |
| 1874 | - .expect("alpha actor fetch must succeed") | |
| 1875 | - .json() | |
| 1876 | - .await | |
| 1877 | - .expect("alpha actor JSON must parse"); | |
| 1878 | - let alpha_public_pem = alpha_actor["publicKey"]["publicKeyPem"] | |
| 1879 | - .as_str() | |
| 1880 | - .unwrap() | |
| 1881 | - .to_string(); | |
| 1882 | - let alpha_inbox = alpha_actor["inbox"].as_str().unwrap().to_string(); | |
| 1883 | - let alpha_actor_id = alpha_actor["id"].as_str().unwrap().to_string(); | |
| 1884 | - | |
| 1885 | - upsert_remote_identity( | |
| 1886 | - "beta", | |
| 1887 | - &alpha_actor_id, | |
| 1888 | - "joanie", | |
| 1889 | - "alpha.local", | |
| 1890 | - &alpha_inbox, | |
| 1891 | - &alpha_public_pem, | |
| 1892 | - ); | |
| 1893 | - | |
| 1894 | - // 3. DB-direct: simulate the missing back-channel by flipping B's | |
| 1895 | - // challenge to `approved` and binding it to the remote_identity we just | |
| 1896 | - // seeded. | |
| 1897 | - psql( | |
| 1898 | - "beta", | |
| 1899 | - &format!( | |
| 1900 | - "UPDATE fediverse_auth_challenges | |
| 1901 | - SET status = 'approved', | |
| 1902 | - requested_remote_id = (SELECT id FROM remote_identities | |
| 1903 | - WHERE ap_id = '{alpha_actor_id}') | |
| 1904 | - WHERE id = '{challenge_id}'" | |
| 1905 | - ), | |
| 1906 | - ); | |
| 1907 | - | |
| 1908 | - // 4. Consume — B must mint a remote JWT from the approved challenge. | |
| 1909 | - let consume = client | |
| 1910 | - .post(format!( | |
| 1911 | - "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1912 | - )) | |
| 1913 | - .send() | |
| 1914 | - .await | |
| 1915 | - .expect("consume request must complete"); | |
| 1916 | - let status = consume.status(); | |
| 1917 | - let body_text = consume.text().await.unwrap_or_default(); | |
| 1918 | - assert_eq!( | |
| 1919 | - status, | |
| 1920 | - StatusCode::OK, | |
| 1921 | - "consume must return 200 for an approved challenge; got {status} body={body_text}" | |
| 1922 | - ); | |
| 1923 | - | |
| 1924 | - let body: Value = serde_json::from_str(&body_text).expect("consume body must be JSON"); | |
| 1925 | - let jwt = body["jwt"].as_str().expect("consume response must carry jwt"); | |
| 1926 | - let parts: Vec<&str> = jwt.split('.').collect(); | |
| 1927 | - assert_eq!(parts.len(), 3, "JWT must have header.payload.signature"); | |
| 1928 | - | |
| 1929 | - // Decode the payload (middle segment) without verifying the signature — | |
| 1930 | - // we just check the claims shape, not crypto. | |
| 1931 | - let payload_b64 = parts[1]; | |
| 1932 | - let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 1933 | - .decode(payload_b64) | |
| 1934 | - .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_b64)) | |
| 1935 | - .expect("JWT payload must be base64-url"); | |
| 1936 | - let claims: Value = serde_json::from_slice(&payload_bytes).expect("JWT payload must be JSON"); | |
| 1937 | - | |
| 1938 | - assert_eq!( | |
| 1939 | - claims["kind"], "remote", | |
| 1940 | - "remote JWT must carry kind=remote, got claims: {claims}" | |
| 1941 | - ); | |
| 1942 | - assert_eq!(claims["username"], "joanie@alpha.local"); | |
| 1943 | - assert!( | |
| 1944 | - claims["remote_id"].is_string(), | |
| 1945 | - "remote JWT must carry remote_id, got claims: {claims}" | |
| 1946 | - ); | |
| 1947 | - | |
| 1948 | - // 5. The challenge must now be marked consumed (single-use guarantee). | |
| 1949 | - let consumed_count = psql( | |
| 1950 | - "beta", | |
| 1951 | - &format!( | |
| 1952 | - "SELECT COUNT(*) FROM fediverse_auth_challenges | |
| 1953 | - WHERE id = '{challenge_id}' AND status = 'consumed'" | |
| 1954 | - ), | |
| 1955 | - ); | |
| 1956 | - assert_eq!(consumed_count, "1", "challenge must be marked consumed"); | |
| 1957 | -} | |
| 1958 | - | |
| 1959 | -#[tokio::test] | |
| 1960 | -async fn auth_inbox_rejects_invalid_signature() { | |
| 1961 | - let Some(client) = testbed_or_skip().await else { | |
| 1962 | - return; | |
| 1963 | - }; | |
| 1964 | - // Bogus Signature header that won't parse / verify. | |
| 1965 | - let response = client | |
| 1966 | - .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 1967 | - .header("Content-Type", "application/activity+json") | |
| 1968 | - .header("Date", "Sun, 10 May 2026 21:00:00 GMT") | |
| 1969 | - .header( | |
| 1970 | - "Signature", | |
| 1971 | - r#"keyId="http://beta.local/users/joanie#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AAAA""#, | |
| 1972 | - ) | |
| 1973 | - .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 1974 | - .send() | |
| 1975 | - .await | |
| 1976 | - .expect("request must succeed"); | |
| 1977 | - assert!( | |
| 1978 | - response.status().is_client_error(), | |
| 1979 | - "invalid signature must yield 4xx, got {}", | |
| 1980 | - response.status() | |
| 1981 | - ); | |
| 1982 | -} |
D
crates/rustwarden-core/tests/fediverse_interop.rs
+0
-315
@@ -1,315 +0,0 @@
| 1 | -//! RFC 0020 — Fediverse interoperability tests. | |
| 2 | -//! | |
| 3 | -//! Spawns a `wiremock` HTTP server playing the role of a remote | |
| 4 | -//! ActivityPub inbox (Mastodon-style) and exercises the signing / | |
| 5 | -//! verification chain end-to-end: | |
| 6 | -//! | |
| 7 | -//! 1. `signed_request_round_trip_through_wiremock` — build + sign + | |
| 8 | -//! POST a request, capture it on the receiver side, re-verify the | |
| 9 | -//! signature against the same public PEM via `parse_and_verify_cavage`. | |
| 10 | -//! 2. `outbound_delivers_when_inbox_returns_202` — `drain_one` with a | |
| 11 | -//! delivery impl that returns `Ok` on 2xx flips the row to | |
| 12 | -//! `Delivered`. | |
| 13 | -//! 3. `outbound_defers_when_inbox_returns_503` — the same harness with | |
| 14 | -//! a 503 leaves the row in `Pending` with `next_retry_at` set per | |
| 15 | -//! RFC 0010. | |
| 16 | - | |
| 17 | -#![cfg(feature = "experimental-fediverse")] | |
| 18 | - | |
| 19 | -use std::sync::Arc; | |
| 20 | - | |
| 21 | -use async_trait::async_trait; | |
| 22 | -use base64::Engine; | |
| 23 | -use chrono::Utc; | |
| 24 | -use reqwest::Client; | |
| 25 | -use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; | |
| 26 | -use uuid::Uuid; | |
| 27 | -use wiremock::matchers::{header_exists, method, path}; | |
| 28 | -use wiremock::{Mock, MockServer, Request, ResponseTemplate}; | |
| 29 | - | |
| 30 | -use rustwarden_core::error::AppError; | |
| 31 | -use rustwarden_core::fediverse::http_signatures::{ | |
| 32 | - build_signature_input, parse_and_verify_cavage, rsa_sha256_sign, | |
| 33 | -}; | |
| 34 | -use rustwarden_core::models::fediverse_outbound_queue; | |
| 35 | -use rustwarden_core::services::actor_key_service::ActorKeyService; | |
| 36 | -use rustwarden_core::services::fediverse_outbound_service::{ | |
| 37 | - FediverseOutboundService, OutboundDelivery, OutboundStatus, | |
| 38 | -}; | |
| 39 | - | |
| 40 | -/// Build the canonical `Signature` header for a draft-cavage signed | |
| 41 | -/// POST. Mirrors `HttpSignedDelivery::deliver` but lives in the test | |
| 42 | -/// module so the round-trip can be exercised without DB plumbing. | |
| 43 | -fn sign_post( | |
| 44 | - private_pem: &str, | |
| 45 | - key_id: &str, | |
| 46 | - host: &str, | |
| 47 | - path: &str, | |
| 48 | - date: &str, | |
| 49 | -) -> String { | |
| 50 | - let signed_headers = vec![ | |
| 51 | - "(request-target)".to_string(), | |
| 52 | - "host".to_string(), | |
| 53 | - "date".to_string(), | |
| 54 | - ]; | |
| 55 | - let request_target = format!("post {path}"); | |
| 56 | - let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 57 | - "host" => Some(host.to_string()), | |
| 58 | - "date" => Some(date.to_string()), | |
| 59 | - _ => None, | |
| 60 | - }) | |
| 61 | - .expect("build_signature_input"); | |
| 62 | - | |
| 63 | - let signature_bytes = | |
| 64 | - rsa_sha256_sign(private_pem, input.as_bytes()).expect("sign"); | |
| 65 | - let signature_b64 = | |
| 66 | - base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 67 | - | |
| 68 | - format!( | |
| 69 | - r#"keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 70 | - ) | |
| 71 | -} | |
| 72 | - | |
| 73 | -#[tokio::test] | |
| 74 | -async fn signed_request_round_trip_through_wiremock() { | |
| 75 | - let server = MockServer::start().await; | |
| 76 | - let inbox_path = "/inbox"; | |
| 77 | - | |
| 78 | - Mock::given(method("POST")) | |
| 79 | - .and(path(inbox_path)) | |
| 80 | - .and(header_exists("signature")) | |
| 81 | - .respond_with(ResponseTemplate::new(202)) | |
| 82 | - .expect(1) | |
| 83 | - .mount(&server) | |
| 84 | - .await; | |
| 85 | - | |
| 86 | - let material = ActorKeyService::generate().expect("RSA gen"); | |
| 87 | - let url = format!("{}{inbox_path}", server.uri()); | |
| 88 | - let parsed = reqwest::Url::parse(&url).unwrap(); | |
| 89 | - let host = parsed.host_str().unwrap().to_string(); | |
| 90 | - let host_with_port = match parsed.port() { | |
| 91 | - Some(p) => format!("{host}:{p}"), | |
| 92 | - None => host.clone(), | |
| 93 | - }; | |
| 94 | - let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 95 | - let key_id = "https://a.tld/users/alice#main-key"; | |
| 96 | - | |
| 97 | - let signature_header = sign_post( | |
| 98 | - &material.private_pem, | |
| 99 | - key_id, | |
| 100 | - &host_with_port, | |
| 101 | - inbox_path, | |
| 102 | - &date, | |
| 103 | - ); | |
| 104 | - | |
| 105 | - let body = br#"{"@context":"https://www.w3.org/ns/activitystreams","type":"Update"}"#; | |
| 106 | - | |
| 107 | - let response = Client::new() | |
| 108 | - .post(&url) | |
| 109 | - .header("Date", &date) | |
| 110 | - .header("Host", &host_with_port) | |
| 111 | - .header("Signature", &signature_header) | |
| 112 | - .header("Content-Type", "application/activity+json") | |
| 113 | - .body(body.to_vec()) | |
| 114 | - .send() | |
| 115 | - .await | |
| 116 | - .expect("post ok"); | |
| 117 | - assert_eq!(response.status().as_u16(), 202); | |
| 118 | - | |
| 119 | - // Capture the request that wiremock received and re-verify the | |
| 120 | - // signature against the published public PEM. This proves the | |
| 121 | - // signed input string is what reqwest actually puts on the wire. | |
| 122 | - let received: Vec<Request> = server.received_requests().await.unwrap(); | |
| 123 | - assert_eq!(received.len(), 1); | |
| 124 | - let req = &received[0]; | |
| 125 | - | |
| 126 | - let header_lookup = |name: &str| -> Option<String> { | |
| 127 | - // wiremock lowercases header names already. | |
| 128 | - req.headers | |
| 129 | - .get(name) | |
| 130 | - .map(|v| v.to_str().unwrap().to_string()) | |
| 131 | - }; | |
| 132 | - | |
| 133 | - let sig = header_lookup("signature").expect("signature header captured"); | |
| 134 | - parse_and_verify_cavage( | |
| 135 | - &sig, | |
| 136 | - &format!("post {inbox_path}"), | |
| 137 | - &req.body, | |
| 138 | - &material.public_pem, | |
| 139 | - header_lookup, | |
| 140 | - ) | |
| 141 | - .expect("signature must round-trip through wiremock"); | |
| 142 | -} | |
| 143 | - | |
| 144 | -/// Minimal `OutboundDelivery` that signs + posts via reqwest using a | |
| 145 | -/// pre-supplied keypair. Mirrors `HttpSignedDelivery::deliver` without | |
| 146 | -/// the DB lookup so we can exercise `drain_one` against wiremock with | |
| 147 | -/// only a `MockDatabase` for the queue. | |
| 148 | -struct InMemorySignedDelivery { | |
| 149 | - private_pem: String, | |
| 150 | - key_id: String, | |
| 151 | -} | |
| 152 | - | |
| 153 | -#[async_trait] | |
| 154 | -impl OutboundDelivery for InMemorySignedDelivery { | |
| 155 | - async fn deliver( | |
| 156 | - &self, | |
| 157 | - item: &fediverse_outbound_queue::Model, | |
| 158 | - ) -> Result<(), AppError> { | |
| 159 | - let url = reqwest::Url::parse(&item.target_inbox) | |
| 160 | - .map_err(|e| AppError::Internal(format!("bad URL: {e}")))?; | |
| 161 | - let host = url | |
| 162 | - .host_str() | |
| 163 | - .ok_or_else(|| AppError::Internal("no host".into()))? | |
| 164 | - .to_string(); | |
| 165 | - let host_with_port = match url.port() { | |
| 166 | - Some(p) => format!("{host}:{p}"), | |
| 167 | - None => host.clone(), | |
| 168 | - }; | |
| 169 | - let path = url.path().to_string(); | |
| 170 | - let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 171 | - | |
| 172 | - let signature_header = sign_post( | |
| 173 | - &self.private_pem, | |
| 174 | - &self.key_id, | |
| 175 | - &host_with_port, | |
| 176 | - &path, | |
| 177 | - &date, | |
| 178 | - ); | |
| 179 | - | |
| 180 | - let response = Client::new() | |
| 181 | - .post(&item.target_inbox) | |
| 182 | - .header("Date", &date) | |
| 183 | - .header("Host", &host_with_port) | |
| 184 | - .header("Signature", &signature_header) | |
| 185 | - .header("Content-Type", "application/activity+json") | |
| 186 | - .body(item.payload_json.clone()) | |
| 187 | - .send() | |
| 188 | - .await | |
| 189 | - .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 190 | - | |
| 191 | - if response.status().is_success() { | |
| 192 | - Ok(()) | |
| 193 | - } else { | |
| 194 | - Err(AppError::Internal(format!( | |
| 195 | - "remote inbox responded {}", | |
| 196 | - response.status(), | |
| 197 | - ))) | |
| 198 | - } | |
| 199 | - } | |
| 200 | -} | |
| 201 | - | |
| 202 | -fn pending_row(target_inbox: &str, target_domain: &str) -> fediverse_outbound_queue::Model { | |
| 203 | - fediverse_outbound_queue::Model { | |
| 204 | - id: Uuid::new_v4(), | |
| 205 | - activity_id: "https://a.tld/activities/1".to_string(), | |
| 206 | - payload_json: r#"{"type":"Update"}"#.to_string(), | |
| 207 | - target_inbox: target_inbox.to_string(), | |
| 208 | - target_domain: target_domain.to_string(), | |
| 209 | - status: "pending".to_string(), | |
| 210 | - attempts: 0, | |
| 211 | - max_attempts: 8, | |
| 212 | - last_error: None, | |
| 213 | - next_retry_at: None, | |
| 214 | - created_at: Utc::now(), | |
| 215 | - delivered_at: None, | |
| 216 | - actor_user_id: Some(Uuid::new_v4()), | |
| 217 | - } | |
| 218 | -} | |
| 219 | - | |
| 220 | -#[tokio::test] | |
| 221 | -async fn outbound_delivers_when_inbox_returns_202() { | |
| 222 | - let server = MockServer::start().await; | |
| 223 | - Mock::given(method("POST")) | |
| 224 | - .and(path("/inbox")) | |
| 225 | - .respond_with(ResponseTemplate::new(202)) | |
| 226 | - .expect(1) | |
| 227 | - .mount(&server) | |
| 228 | - .await; | |
| 229 | - | |
| 230 | - let target_inbox = format!("{}/inbox", server.uri()); | |
| 231 | - let row = pending_row(&target_inbox, "wiremock.local"); | |
| 232 | - | |
| 233 | - // Two fixtures: the SELECT-candidate then the SELECT-after-update | |
| 234 | - // (sea-orm mock returns subsequent fixtures for `.update()` reads). | |
| 235 | - let mut delivered = row.clone(); | |
| 236 | - delivered.status = OutboundStatus::Delivered.as_str().to_string(); | |
| 237 | - delivered.attempts = 1; | |
| 238 | - delivered.delivered_at = Some(Utc::now()); | |
| 239 | - | |
| 240 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 241 | - .append_query_results([vec![row.clone()]]) | |
| 242 | - .append_query_results([vec![delivered.clone()]]) | |
| 243 | - .append_exec_results([MockExecResult { | |
| 244 | - last_insert_id: 0, | |
| 245 | - rows_affected: 1, | |
| 246 | - }]) | |
| 247 | - .into_connection(); | |
| 248 | - | |
| 249 | - let material = ActorKeyService::generate().expect("RSA gen"); | |
| 250 | - let delivery = InMemorySignedDelivery { | |
| 251 | - private_pem: material.private_pem, | |
| 252 | - key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 253 | - }; | |
| 254 | - | |
| 255 | - let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 256 | - .await | |
| 257 | - .expect("drain ok") | |
| 258 | - .expect("row drained"); | |
| 259 | - | |
| 260 | - assert_eq!(result.status, OutboundStatus::Delivered.as_str()); | |
| 261 | - assert_eq!(result.attempts, 1); | |
| 262 | - assert!(result.delivered_at.is_some()); | |
| 263 | -} | |
| 264 | - | |
| 265 | -#[tokio::test] | |
| 266 | -async fn outbound_defers_when_inbox_returns_503() { | |
| 267 | - let server = MockServer::start().await; | |
| 268 | - Mock::given(method("POST")) | |
| 269 | - .and(path("/inbox")) | |
| 270 | - .respond_with(ResponseTemplate::new(503)) | |
| 271 | - .expect(1) | |
| 272 | - .mount(&server) | |
| 273 | - .await; | |
| 274 | - | |
| 275 | - let target_inbox = format!("{}/inbox", server.uri()); | |
| 276 | - let row = pending_row(&target_inbox, "wiremock.local"); | |
| 277 | - | |
| 278 | - let mut deferred = row.clone(); | |
| 279 | - deferred.status = OutboundStatus::Pending.as_str().to_string(); | |
| 280 | - deferred.attempts = 1; | |
| 281 | - deferred.last_error = Some("remote inbox responded 503".to_string()); | |
| 282 | - deferred.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); | |
| 283 | - | |
| 284 | - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 285 | - .append_query_results([vec![row.clone()]]) | |
| 286 | - .append_query_results([vec![deferred.clone()]]) | |
| 287 | - .append_exec_results([MockExecResult { | |
| 288 | - last_insert_id: 0, | |
| 289 | - rows_affected: 1, | |
| 290 | - }]) | |
| 291 | - .into_connection(); | |
| 292 | - | |
| 293 | - let material = ActorKeyService::generate().expect("RSA gen"); | |
| 294 | - let delivery = InMemorySignedDelivery { | |
| 295 | - private_pem: material.private_pem, | |
| 296 | - key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 297 | - }; | |
| 298 | - | |
| 299 | - let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 300 | - .await | |
| 301 | - .expect("drain ok") | |
| 302 | - .expect("row drained"); | |
| 303 | - | |
| 304 | - assert_eq!(result.status, OutboundStatus::Pending.as_str()); | |
| 305 | - assert_eq!(result.attempts, 1); | |
| 306 | - assert!(result.last_error.is_some()); | |
| 307 | - assert!(result.next_retry_at.is_some()); | |
| 308 | -} | |
| 309 | - | |
| 310 | -// `Arc` is unused in this file but documents the intent of sharing the | |
| 311 | -// MockServer handle across spawned tasks should we extend the harness. | |
| 312 | -#[allow(dead_code)] | |
| 313 | -fn _share_server(server: MockServer) -> Arc<MockServer> { | |
| 314 | - Arc::new(server) | |
| 315 | -} |
A
crates/rustwarden-fediverse/Cargo.toml
+57
-0
@@ -0,0 +1,57 @@
| 1 | +[package] | |
| 2 | +name = "rustwarden-fediverse" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition.workspace = true | |
| 5 | +rust-version.workspace = true | |
| 6 | +description = "ActivityPub federation for Rustwarden — WebFinger, actor, inbox, HTTP signatures, AP-native auth, cross-instance team invitations" | |
| 7 | + | |
| 8 | +[dependencies] | |
| 9 | +rustwarden-core = { path = "../rustwarden-core" } | |
| 10 | +# Web framework (routes, handlers) | |
| 11 | +axum = { version = "0.8", features = ["macros"] } | |
| 12 | +tokio = { version = "1", features = ["full"] } | |
| 13 | +async-trait = "0.1" | |
| 14 | +futures = "0.3" | |
| 15 | +tokio-stream = { version = "0.1", features = ["sync"] } | |
| 16 | +# Tower middleware | |
| 17 | +tower = { version = "0.5", features = ["limit", "util"] } | |
| 18 | +# Serialization | |
| 19 | +serde = { version = "1.0", features = ["derive"] } | |
| 20 | +serde_json = "1.0" | |
| 21 | +# Tracing | |
| 22 | +tracing = "0.1" | |
| 23 | +# ORM | |
| 24 | +sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } | |
| 25 | +# Time & identifiers | |
| 26 | +chrono = { version = "0.4", features = ["serde"] } | |
| 27 | +uuid = { version = "1.0", features = ["v4", "serde"] } | |
| 28 | +# Error handling | |
| 29 | +thiserror = "2.0" | |
| 30 | +# Crypto helpers | |
| 31 | +sha2 = "0.10" | |
| 32 | +base64 = "0.22" | |
| 33 | +hex = "0.4" | |
| 34 | +subtle = "2.6" | |
| 35 | +rand = { version = "0.9", features = ["std"] } | |
| 36 | +# URL handling | |
| 37 | +url = { version = "2", features = ["serde"] } | |
| 38 | +urlencoding = "2" | |
| 39 | +# ActivityPub | |
| 40 | +activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false } | |
| 41 | +# HTML sanitization for incoming AP payloads | |
| 42 | +ammonia = "4" | |
| 43 | +# RSA actor keys (RFC 0005) | |
| 44 | +rsa = { version = "0.9", features = ["pem", "getrandom"] } | |
| 45 | +# Outbound HTTP delivery | |
| 46 | +reqwest = { version = "0.12", features = ["json", "native-tls"] } | |
| 47 | +# JWT (claims, validation) | |
| 48 | +jsonwebtoken = { version = "10", features = ["rust_crypto"] } | |
| 49 | +# Validation | |
| 50 | +validator = { version = "0.20", features = ["derive"] } | |
| 51 | + | |
| 52 | +[lints] | |
| 53 | +workspace = true | |
| 54 | + | |
| 55 | +[dev-dependencies] | |
| 56 | +sea-orm = { version = "2.0.0-rc.20", features = ["mock"] } | |
| 57 | +wiremock = "0.6" |
A
crates/rustwarden-fediverse/src/actor.rs
+310
-0
@@ -0,0 +1,310 @@
| 1 | +//! Person actor handler with `Accept` content negotiation. | |
| 2 | +//! | |
| 3 | +//! Mounted at `GET /users/{username}`. | |
| 4 | + | |
| 5 | +use axum::{ | |
| 6 | + Extension, Json, | |
| 7 | + extract::Path, | |
| 8 | + http::{HeaderMap, HeaderValue, StatusCode, header}, | |
| 9 | + response::{IntoResponse, Response}, | |
| 10 | +}; | |
| 11 | +use url::Url; | |
| 12 | + | |
| 13 | +use super::error::FediverseError; | |
| 14 | +use super::resolver::UserSummary; | |
| 15 | +use super::state::FediverseState; | |
| 16 | +use super::types::{EndpointsJson, PersonJson, PublicKeyJson, actor_context}; | |
| 17 | + | |
| 18 | +/// `GET /users/{username}`. | |
| 19 | +/// | |
| 20 | +/// Negotiates between ActivityPub JSON and HTML based on the `Accept` header: | |
| 21 | +/// | |
| 22 | +/// | `Accept` value contains… | Response | | |
| 23 | +/// |---|---| | |
| 24 | +/// | `application/activity+json` | `200` JSON Person | | |
| 25 | +/// | `application/ld+json` | `200` JSON Person | | |
| 26 | +/// | `*/*` (e.g. `curl` default) | `200` JSON Person | | |
| 27 | +/// | (no `Accept` header) | `200` JSON Person (Mastodon convention) | | |
| 28 | +/// | only `text/html` (and UI not mounted at this layer) | `406 Not Acceptable` | | |
| 29 | +pub async fn handle( | |
| 30 | + Path(username): Path<String>, | |
| 31 | + headers: HeaderMap, | |
| 32 | + Extension(state): Extension<FediverseState>, | |
| 33 | +) -> Result<Response, FediverseError> { | |
| 34 | + if !prefers_activitypub(&headers) { | |
| 35 | + return Err(FediverseError::NotAcceptable); | |
| 36 | + } | |
| 37 | + | |
| 38 | + let user = state | |
| 39 | + .resolver | |
| 40 | + .find_local_user(&username) | |
| 41 | + .await? | |
| 42 | + .ok_or(FediverseError::UserNotFound)?; | |
| 43 | + | |
| 44 | + let person = build_person_json(&state, &user)?; | |
| 45 | + | |
| 46 | + Ok(( | |
| 47 | + StatusCode::OK, | |
| 48 | + [( | |
| 49 | + header::CONTENT_TYPE, | |
| 50 | + HeaderValue::from_static("application/activity+json; charset=utf-8"), | |
| 51 | + )], | |
| 52 | + Json(person), | |
| 53 | + ) | |
| 54 | + .into_response()) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Inspect an `Accept` header and decide whether the caller will accept the | |
| 58 | +/// ActivityPub JSON representation. | |
| 59 | +/// | |
| 60 | +/// Per Mastodon convention, an absent `Accept` header is treated as a | |
| 61 | +/// preference for ActivityPub (AP `id` URLs are the canonical resource). | |
| 62 | +pub(crate) fn prefers_activitypub(headers: &HeaderMap) -> bool { | |
| 63 | + let Some(value) = headers | |
| 64 | + .get(header::ACCEPT) | |
| 65 | + .and_then(|v| v.to_str().ok()) | |
| 66 | + else { | |
| 67 | + return true; | |
| 68 | + }; | |
| 69 | + | |
| 70 | + let lower = value.to_ascii_lowercase(); | |
| 71 | + lower.contains("application/activity+json") | |
| 72 | + || lower.contains("application/ld+json") | |
| 73 | + || lower.contains("*/*") | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// Build a [`PersonJson`] for a local user, using base URLs derived from the | |
| 77 | +/// federation config. | |
| 78 | +pub(crate) fn build_person_json( | |
| 79 | + state: &FediverseState, | |
| 80 | + user: &UserSummary, | |
| 81 | +) -> Result<PersonJson, FediverseError> { | |
| 82 | + let base = state.config.base_url(); | |
| 83 | + let actor_url = url_join(base, &format!("users/{}", user.username))?; | |
| 84 | + let inbox = url_join(base, &format!("users/{}/inbox", user.username))?; | |
| 85 | + let outbox = url_join(base, &format!("users/{}/outbox", user.username))?; | |
| 86 | + let followers = url_join(base, &format!("users/{}/followers", user.username))?; | |
| 87 | + let following = url_join(base, &format!("users/{}/following", user.username))?; | |
| 88 | + let profile = url_join(base, &format!("@{}", user.username))?; | |
| 89 | + let shared_inbox = url_join(base, "inbox")?; | |
| 90 | + | |
| 91 | + let public_key = user.ap_public_key_pem.as_ref().map(|pem| PublicKeyJson { | |
| 92 | + id: append_fragment(&actor_url, "main-key"), | |
| 93 | + owner: actor_url.clone(), | |
| 94 | + public_key_pem: pem.clone(), | |
| 95 | + }); | |
| 96 | + | |
| 97 | + Ok(PersonJson { | |
| 98 | + context: actor_context(), | |
| 99 | + kind: "Person".to_string(), | |
| 100 | + id: actor_url, | |
| 101 | + preferred_username: user.username.clone(), | |
| 102 | + name: user.display_name.clone(), | |
| 103 | + summary: user.bio.clone(), | |
| 104 | + url: Some(profile), | |
| 105 | + inbox, | |
| 106 | + outbox, | |
| 107 | + followers, | |
| 108 | + following, | |
| 109 | + endpoints: Some(EndpointsJson { shared_inbox }), | |
| 110 | + public_key, | |
| 111 | + manually_approves_followers: false, | |
| 112 | + discoverable: true, | |
| 113 | + }) | |
| 114 | +} | |
| 115 | + | |
| 116 | +fn url_join(base: &Url, path: &str) -> Result<Url, FediverseError> { | |
| 117 | + base.join(path) | |
| 118 | + .map_err(|e| FediverseError::App(rustwarden_core::error::AppError::Internal(format!( | |
| 119 | + "failed to build actor URL from {base} + {path}: {e}" | |
| 120 | + )))) | |
| 121 | +} | |
| 122 | + | |
| 123 | +fn append_fragment(url: &Url, fragment: &str) -> Url { | |
| 124 | + let mut copy = url.clone(); | |
| 125 | + copy.set_fragment(Some(fragment)); | |
| 126 | + copy | |
| 127 | +} | |
| 128 | + | |
| 129 | +#[cfg(test)] | |
| 130 | +mod tests { | |
| 131 | + use super::*; | |
| 132 | + use crate::config::FediverseConfig; | |
| 133 | + use crate::resolver::{LocalUserResolver, UserSummary}; | |
| 134 | + use async_trait::async_trait; | |
| 135 | + use axum::Router; | |
| 136 | + use axum::body::Body; | |
| 137 | + use axum::http::Request; | |
| 138 | + use axum::routing::get; | |
| 139 | + use std::sync::Arc; | |
| 140 | + use tower::ServiceExt; | |
| 141 | + use uuid::Uuid; | |
| 142 | + | |
| 143 | + struct StubResolver { | |
| 144 | + user: Option<UserSummary>, | |
| 145 | + } | |
| 146 | + | |
| 147 | + #[async_trait] | |
| 148 | + impl LocalUserResolver for StubResolver { | |
| 149 | + async fn find_local_user( | |
| 150 | + &self, | |
| 151 | + _username: &str, | |
| 152 | + ) -> Result<Option<UserSummary>, rustwarden_core::error::AppError> { | |
| 153 | + Ok(self.user.clone()) | |
| 154 | + } | |
| 155 | + async fn count_users_total(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 156 | + Ok(0) | |
| 157 | + } | |
| 158 | + async fn count_users_active_since(&self, _days: u32) -> Result<u64, rustwarden_core::error::AppError> { | |
| 159 | + Ok(0) | |
| 160 | + } | |
| 161 | + async fn count_local_posts(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 162 | + Ok(0) | |
| 163 | + } | |
| 164 | + } | |
| 165 | + | |
| 166 | + fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router { | |
| 167 | + let state = FediverseState { | |
| 168 | + config: FediverseConfig::for_tests(domain), | |
| 169 | + resolver: Arc::new(StubResolver { user }), | |
| 170 | + }; | |
| 171 | + Router::new() | |
| 172 | + .route("/users/{username}", get(handle)) | |
| 173 | + .layer(Extension(state)) | |
| 174 | + } | |
| 175 | + | |
| 176 | + fn alice() -> UserSummary { | |
| 177 | + UserSummary { | |
| 178 | + id: Uuid::new_v4(), | |
| 179 | + username: "alice".into(), | |
| 180 | + display_name: Some("Alice".into()), | |
| 181 | + bio: Some("<p>Hello</p>".into()), | |
| 182 | + ap_public_key_pem: None, | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + async fn body_json(response: Response) -> serde_json::Value { | |
| 187 | + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 188 | + .await | |
| 189 | + .expect("body bytes"); | |
| 190 | + serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 191 | + } | |
| 192 | + | |
| 193 | + #[tokio::test] | |
| 194 | + async fn actor_returns_person_json_with_ap_accept() { | |
| 195 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 196 | + | |
| 197 | + let response = app | |
| 198 | + .oneshot( | |
| 199 | + Request::builder() | |
| 200 | + .uri("/users/alice") | |
| 201 | + .header(header::ACCEPT, "application/activity+json") | |
| 202 | + .body(Body::empty()) | |
| 203 | + .unwrap(), | |
| 204 | + ) | |
| 205 | + .await | |
| 206 | + .unwrap(); | |
| 207 | + | |
| 208 | + assert_eq!(response.status(), StatusCode::OK); | |
| 209 | + let ct = response | |
| 210 | + .headers() | |
| 211 | + .get(header::CONTENT_TYPE) | |
| 212 | + .and_then(|v| v.to_str().ok()) | |
| 213 | + .unwrap_or_default() | |
| 214 | + .to_string(); | |
| 215 | + assert!( | |
| 216 | + ct.contains("application/activity+json"), | |
| 217 | + "content-type={ct}" | |
| 218 | + ); | |
| 219 | + | |
| 220 | + let body = body_json(response).await; | |
| 221 | + assert_eq!(body["type"], "Person"); | |
| 222 | + assert_eq!(body["id"], "https://example.org/users/alice"); | |
| 223 | + assert_eq!(body["preferredUsername"], "alice"); | |
| 224 | + assert_eq!(body["inbox"], "https://example.org/users/alice/inbox"); | |
| 225 | + assert_eq!(body["outbox"], "https://example.org/users/alice/outbox"); | |
| 226 | + assert_eq!(body["followers"], "https://example.org/users/alice/followers"); | |
| 227 | + assert_eq!(body["following"], "https://example.org/users/alice/following"); | |
| 228 | + assert!(body.get("publicKey").is_none() || body["publicKey"].is_null()); | |
| 229 | + } | |
| 230 | + | |
| 231 | + #[tokio::test] | |
| 232 | + async fn actor_returns_person_json_with_ld_json_accept() { | |
| 233 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 234 | + let response = app | |
| 235 | + .oneshot( | |
| 236 | + Request::builder() | |
| 237 | + .uri("/users/alice") | |
| 238 | + .header( | |
| 239 | + header::ACCEPT, | |
| 240 | + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", | |
| 241 | + ) | |
| 242 | + .body(Body::empty()) | |
| 243 | + .unwrap(), | |
| 244 | + ) | |
| 245 | + .await | |
| 246 | + .unwrap(); | |
| 247 | + assert_eq!(response.status(), StatusCode::OK); | |
| 248 | + let body = body_json(response).await; | |
| 249 | + assert_eq!(body["type"], "Person"); | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[tokio::test] | |
| 253 | + async fn actor_returns_404_for_unknown_user() { | |
| 254 | + let app = router_with_resolver("example.org", None); | |
| 255 | + let response = app | |
| 256 | + .oneshot( | |
| 257 | + Request::builder() | |
| 258 | + .uri("/users/ghost") | |
| 259 | + .header(header::ACCEPT, "application/activity+json") | |
| 260 | + .body(Body::empty()) | |
| 261 | + .unwrap(), | |
| 262 | + ) | |
| 263 | + .await | |
| 264 | + .unwrap(); | |
| 265 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 266 | + } | |
| 267 | + | |
| 268 | + #[tokio::test] | |
| 269 | + async fn actor_returns_406_for_html_when_no_ui_handler() { | |
| 270 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 271 | + let response = app | |
| 272 | + .oneshot( | |
| 273 | + Request::builder() | |
| 274 | + .uri("/users/alice") | |
| 275 | + .header(header::ACCEPT, "text/html") | |
| 276 | + .body(Body::empty()) | |
| 277 | + .unwrap(), | |
| 278 | + ) | |
| 279 | + .await | |
| 280 | + .unwrap(); | |
| 281 | + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); | |
| 282 | + } | |
| 283 | + | |
| 284 | + #[tokio::test] | |
| 285 | + async fn actor_default_accept_returns_ap_json() { | |
| 286 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 287 | + let response = app | |
| 288 | + .oneshot( | |
| 289 | + Request::builder() | |
| 290 | + .uri("/users/alice") | |
| 291 | + .body(Body::empty()) | |
| 292 | + .unwrap(), | |
| 293 | + ) | |
| 294 | + .await | |
| 295 | + .unwrap(); | |
| 296 | + assert_eq!(response.status(), StatusCode::OK); | |
| 297 | + let body = body_json(response).await; | |
| 298 | + assert_eq!(body["type"], "Person"); | |
| 299 | + } | |
| 300 | + | |
| 301 | + #[test] | |
| 302 | + fn prefers_ap_for_explicit_accept() { | |
| 303 | + let mut h = HeaderMap::new(); | |
| 304 | + h.insert( | |
| 305 | + header::ACCEPT, | |
| 306 | + HeaderValue::from_static("application/activity+json"), | |
| 307 | + ); | |
| 308 | + assert!(prefers_activitypub(&h)); | |
| 309 | + } | |
| 310 | +} |
A
crates/rustwarden-fediverse/src/auth_routes.rs
+639
-0
@@ -0,0 +1,639 @@
| 1 | +//! HTTP routes for the AP-native authentication flow (RFC 0013). | |
| 2 | +//! | |
| 3 | +//! Five thin handlers around [`crate::services::fediverse_auth_service::FediverseAuthService`]: | |
| 4 | +//! | |
| 5 | +//! | Path | Verb | Caller side | | |
| 6 | +//! |---|---|---| | |
| 7 | +//! | `/api/v1/auth/login/fediverse/initiate` | POST | client (`b.tld`) | | |
| 8 | +//! | `/api/v1/auth/login/fediverse/status/{id}` | GET | client | | |
| 9 | +//! | `/api/v1/auth/login/fediverse/consume/{id}` | POST | client | | |
| 10 | +//! | `/fediverse/auth/inbox` | POST | IdP (`a.tld`) | | |
| 11 | +//! | `/api/v1/fediverse/auth/consent` | POST | IdP | | |
| 12 | +//! | |
| 13 | +//! Gated by `experimental-fediverse`. | |
| 14 | + | |
| 15 | +use std::sync::Arc; | |
| 16 | +use std::time::Duration; | |
| 17 | + | |
| 18 | +use axum::{ | |
| 19 | + Extension, Json, Router, | |
| 20 | + extract::Path, | |
| 21 | + http::{HeaderMap, StatusCode, header}, | |
| 22 | + routing::{get, post}, | |
| 23 | +}; | |
| 24 | +use sea_orm::DatabaseConnection; | |
| 25 | +use serde::{Deserialize, Serialize}; | |
| 26 | +use uuid::Uuid; | |
| 27 | + | |
| 28 | +use super::error::FediverseError; | |
| 29 | +use rustwarden_core::config::auth::AuthConfig; | |
| 30 | +use crate::services::fediverse_auth_service::{ | |
| 31 | + ChallengeDecision, FediverseAuthService, InitiateRequest, | |
| 32 | +}; | |
| 33 | +#[cfg(test)] | |
| 34 | +use crate::services::fediverse_auth_service::ChallengeStatus; | |
| 35 | +use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 36 | + | |
| 37 | +/// Per-router state injected into the auth-flow handlers. | |
| 38 | +#[derive(Clone)] | |
| 39 | +pub struct AuthFlowState { | |
| 40 | + /// Same `AuthConfig` as the local login flow — used to mint the | |
| 41 | + /// remote JWT on `consume`. | |
| 42 | + pub auth_config: Arc<AuthConfig>, | |
| 43 | + /// Pluggable signature verifier — used by `auth/inbox` to gate | |
| 44 | + /// inbound `AuthRequest` activities. | |
| 45 | + pub verifier: Arc<dyn SignatureVerifier>, | |
| 46 | + /// TTL of every freshly-initiated challenge. | |
| 47 | + pub challenge_ttl: Duration, | |
| 48 | +} | |
| 49 | + | |
| 50 | +/// `POST /api/v1/auth/login/fediverse/initiate` body. | |
| 51 | +#[derive(Debug, Deserialize)] | |
| 52 | +pub struct InitiateBody { | |
| 53 | + /// `@user@domain` handle the user typed. | |
| 54 | + pub handle: String, | |
| 55 | + /// Optional URL to redirect to after successful consume. | |
| 56 | + pub redirect_url: Option<String>, | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// `POST /api/v1/auth/login/fediverse/initiate` 201 response. | |
| 60 | +#[derive(Debug, Serialize)] | |
| 61 | +pub struct InitiateResponse { | |
| 62 | + /// Stable challenge identifier. | |
| 63 | + pub challenge_id: Uuid, | |
| 64 | + /// Cryptographic nonce embedded in the AP `AuthRequest` activity. | |
| 65 | + pub nonce: String, | |
| 66 | + /// RFC3339 expiry timestamp. | |
| 67 | + pub expires_at: String, | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// `GET /api/v1/auth/login/fediverse/status/{id}` 200 response. | |
| 71 | +#[derive(Debug, Serialize)] | |
| 72 | +pub struct StatusResponse { | |
| 73 | + /// Same identifier as the request. | |
| 74 | + pub challenge_id: Uuid, | |
| 75 | + /// Wire form (`pending`, `approved`, `rejected`, `expired`, `consumed`). | |
| 76 | + pub status: String, | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// `POST /api/v1/auth/login/fediverse/consume/{id}` 200 response. | |
| 80 | +#[derive(Debug, Serialize)] | |
| 81 | +pub struct ConsumeResponse { | |
| 82 | + /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`). | |
| 83 | + pub jwt: String, | |
| 84 | + /// FK of the federated identity the JWT speaks for. | |
| 85 | + pub remote_identity_id: Uuid, | |
| 86 | + /// `@user@domain` handle for log/audit cohesion. | |
| 87 | + pub handle: String, | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// `POST /api/v1/fediverse/auth/consent` body. | |
| 91 | +#[derive(Debug, Deserialize)] | |
| 92 | +pub struct ConsentBody { | |
| 93 | + /// Challenge being decided on. | |
| 94 | + pub challenge_id: Uuid, | |
| 95 | + /// `"approve"` or `"reject"`. | |
| 96 | + pub decision: String, | |
| 97 | + /// On approve only: which `remote_identities.id` is bound to the | |
| 98 | + /// IdP's user that clicked approve. The handler trusts this value | |
| 99 | + /// because the request itself is authenticated as the IdP user | |
| 100 | + /// (CSRF-protected by the consumer middleware). | |
| 101 | + pub remote_identity_id: Option<Uuid>, | |
| 102 | +} | |
| 103 | + | |
| 104 | +// ─── Handlers ──────────────────────────────────────────────────────── | |
| 105 | + | |
| 106 | +async fn initiate( | |
| 107 | + Extension(state): Extension<AuthFlowState>, | |
| 108 | + Extension(db): Extension<DatabaseConnection>, | |
| 109 | + Json(body): Json<InitiateBody>, | |
| 110 | +) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> { | |
| 111 | + let challenge = FediverseAuthService::initiate( | |
| 112 | + &db, | |
| 113 | + InitiateRequest { | |
| 114 | + handle: body.handle, | |
| 115 | + redirect_url: body.redirect_url, | |
| 116 | + }, | |
| 117 | + state.challenge_ttl, | |
| 118 | + ) | |
| 119 | + .await?; | |
| 120 | + Ok(( | |
| 121 | + StatusCode::CREATED, | |
| 122 | + Json(InitiateResponse { | |
| 123 | + challenge_id: challenge.id, | |
| 124 | + nonce: challenge.nonce, | |
| 125 | + expires_at: challenge.expires_at.to_rfc3339(), | |
| 126 | + }), | |
| 127 | + )) | |
| 128 | +} | |
| 129 | + | |
| 130 | +async fn status( | |
| 131 | + Extension(_state): Extension<AuthFlowState>, | |
| 132 | + Extension(db): Extension<DatabaseConnection>, | |
| 133 | + Path(challenge_id): Path<Uuid>, | |
| 134 | +) -> Result<Json<StatusResponse>, FediverseError> { | |
| 135 | + let model = FediverseAuthService::status(&db, challenge_id).await?; | |
| 136 | + Ok(Json(StatusResponse { | |
| 137 | + challenge_id: model.id, | |
| 138 | + status: model.status, | |
| 139 | + })) | |
| 140 | +} | |
| 141 | + | |
| 142 | +async fn consume( | |
| 143 | + Extension(state): Extension<AuthFlowState>, | |
| 144 | + Extension(db): Extension<DatabaseConnection>, | |
| 145 | + Path(challenge_id): Path<Uuid>, | |
| 146 | +) -> Result<Json<ConsumeResponse>, FediverseError> { | |
| 147 | + let session = | |
| 148 | + FediverseAuthService::consume(&db, &state.auth_config, challenge_id).await?; | |
| 149 | + Ok(Json(ConsumeResponse { | |
| 150 | + jwt: session.jwt, | |
| 151 | + remote_identity_id: session.remote_identity_id, | |
| 152 | + handle: session.handle, | |
| 153 | + })) | |
| 154 | +} | |
| 155 | + | |
| 156 | +async fn auth_inbox( | |
| 157 | + Extension(state): Extension<AuthFlowState>, | |
| 158 | + Extension(db): Extension<DatabaseConnection>, | |
| 159 | + headers: HeaderMap, | |
| 160 | + body: axum::body::Bytes, | |
| 161 | +) -> Result<StatusCode, FediverseError> { | |
| 162 | + let signature = headers | |
| 163 | + .get(header::HeaderName::from_static("signature")) | |
| 164 | + .and_then(|v| v.to_str().ok()) | |
| 165 | + .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?; | |
| 166 | + | |
| 167 | + // Parse the body once — we need both the actor (for verification) | |
| 168 | + // and the full payload (for `persist_inbound_request` below). | |
| 169 | + let body_json = serde_json::from_slice::<serde_json::Value>(&body) | |
| 170 | + .map_err(|e| FediverseError::BadRequest(format!("malformed AuthRequest JSON: {e}")))?; | |
| 171 | + let actor = body_json | |
| 172 | + .get("actor") | |
| 173 | + .and_then(|v| v.as_str()) | |
| 174 | + .map(str::to_string) | |
| 175 | + .unwrap_or_default(); | |
| 176 | + | |
| 177 | + let _ = signature; // presence already enforced above | |
| 178 | + | |
| 179 | + // F8 / RFC 0027 — enforce trust policy BEFORE signature verification | |
| 180 | + // (and before any actor fetch). A hostile but signature-valid | |
| 181 | + // instance must be rejected here based on its domain alone. | |
| 182 | + let source_domain = url::Url::parse(&actor) | |
| 183 | + .ok() | |
| 184 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 185 | + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 186 | + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 187 | + .assert_domain_allowed(&source_domain) | |
| 188 | + .await | |
| 189 | + .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 190 | + | |
| 191 | + state | |
| 192 | + .verifier | |
| 193 | + .verify(&VerifyCtx { | |
| 194 | + actor_ap_id: &actor, | |
| 195 | + method: "post", | |
| 196 | + path: "/fediverse/auth/inbox", | |
| 197 | + headers: &headers, | |
| 198 | + body: &body, | |
| 199 | + public_key_pem: "", | |
| 200 | + }) | |
| 201 | + .await | |
| 202 | + .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; | |
| 203 | + | |
| 204 | + // Dispatch on the activity `type` (RFC 0025). | |
| 205 | + // | |
| 206 | + // - `AuthRequest` → IdP side: persist a Pending challenge tied to | |
| 207 | + // the requester (RFC 0006 Phase 2). | |
| 208 | + // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side: | |
| 209 | + // propagate the IdP's decision into the local challenge (RFC 0025). | |
| 210 | + // - Anything else → AP §7 says ignore unknown types silently; we | |
| 211 | + // return 202 and log to ease debugging. | |
| 212 | + let activity_type = body_json | |
| 213 | + .get("type") | |
| 214 | + .and_then(|v| v.as_str()) | |
| 215 | + .unwrap_or("") | |
| 216 | + .to_string(); | |
| 217 | + | |
| 218 | + match activity_type.as_str() { | |
| 219 | + "AuthRequest" => { | |
| 220 | + // Resolve this IdP's canonical domain from the env (set by | |
| 221 | + // `with_fediverse()` at app boot) for the target-validation | |
| 222 | + // check. Strip scheme/trailing-slash so the comparison against | |
| 223 | + // `target`'s bare host part inside `persist_inbound_request` | |
| 224 | + // works whether FEDIVERSE_DOMAIN was set as `example.org` or | |
| 225 | + // `http://example.org`. (TODO: thread the parsed | |
| 226 | + // FediverseConfig in via extension to avoid env reads here.) | |
| 227 | + let idp_domain = std::env::var("FEDIVERSE_DOMAIN") | |
| 228 | + .unwrap_or_default() | |
| 229 | + .trim_start_matches("http://") | |
| 230 | + .trim_start_matches("https://") | |
| 231 | + .trim_end_matches('/') | |
| 232 | + .to_ascii_lowercase(); | |
| 233 | + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) | |
| 234 | + .await | |
| 235 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 236 | + } | |
| 237 | + "Accept" | "Reject" => { | |
| 238 | + FediverseAuthService::record_inbound_decision(&db, &body_json) | |
| 239 | + .await | |
| 240 | + .map_err(|e| FediverseError::BadRequest(e.to_string()))?; | |
| 241 | + } | |
| 242 | + other => { | |
| 243 | + tracing::debug!( | |
| 244 | + activity_type = other, | |
| 245 | + "auth_inbox: ignoring unknown activity type" | |
| 246 | + ); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + Ok(StatusCode::ACCEPTED) | |
| 251 | +} | |
| 252 | + | |
| 253 | +async fn consent( | |
| 254 | + Extension(_state): Extension<AuthFlowState>, | |
| 255 | + Extension(db): Extension<DatabaseConnection>, | |
| 256 | + Json(body): Json<ConsentBody>, | |
| 257 | +) -> Result<StatusCode, FediverseError> { | |
| 258 | + let decision = match body.decision.as_str() { | |
| 259 | + "approve" => { | |
| 260 | + let remote = body.remote_identity_id.ok_or_else(|| { | |
| 261 | + FediverseError::BadRequest( | |
| 262 | + "approve requires remote_identity_id".into(), | |
| 263 | + ) | |
| 264 | + })?; | |
| 265 | + ChallengeDecision::Approve { | |
| 266 | + remote_identity_id: remote, | |
| 267 | + } | |
| 268 | + } | |
| 269 | + "reject" => ChallengeDecision::Reject, | |
| 270 | + other => { | |
| 271 | + return Err(FediverseError::BadRequest(format!( | |
| 272 | + "invalid decision '{other}'", | |
| 273 | + ))); | |
| 274 | + } | |
| 275 | + }; | |
| 276 | + FediverseAuthService::record_decision(&db, body.challenge_id, decision).await?; | |
| 277 | + Ok(StatusCode::OK) | |
| 278 | +} | |
| 279 | + | |
| 280 | +/// Build the auth-flow router. Mounted by | |
| 281 | +/// [`crate::fediverse::routes::router_with`] alongside the discovery | |
| 282 | +/// + lifecycle inbox routes. | |
| 283 | +pub fn router(state: AuthFlowState) -> Router<DatabaseConnection> { | |
| 284 | + Router::new() | |
| 285 | + .route("/api/v1/auth/login/fediverse/initiate", post(initiate)) | |
| 286 | + .route( | |
| 287 | + "/api/v1/auth/login/fediverse/status/{challenge_id}", | |
| 288 | + get(status), | |
| 289 | + ) | |
| 290 | + .route( | |
| 291 | + "/api/v1/auth/login/fediverse/consume/{challenge_id}", | |
| 292 | + post(consume), | |
| 293 | + ) | |
| 294 | + .route("/fediverse/auth/inbox", post(auth_inbox)) | |
| 295 | + .route("/api/v1/fediverse/auth/consent", post(consent)) | |
| 296 | + .layer(Extension(state)) | |
| 297 | +} | |
| 298 | + | |
| 299 | +#[cfg(test)] | |
| 300 | +mod tests { | |
| 301 | + use super::*; | |
| 302 | + use rustwarden_core::error::AppError; | |
| 303 | + use crate::models::fediverse_auth_challenge; | |
| 304 | + use async_trait::async_trait; | |
| 305 | + use axum::body::Body; | |
| 306 | + use axum::http::Request; | |
| 307 | + use chrono::{Duration as ChronoDuration, Utc}; | |
| 308 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 309 | + use tower::ServiceExt; | |
| 310 | + | |
| 311 | + /// F8 enforcement helper for unit tests: pre-queues three mock | |
| 312 | + /// results (app_settings.fediverse_enabled=true, | |
| 313 | + /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so | |
| 314 | + /// `FediverseTrustService::assert_domain_allowed` answers Ok for | |
| 315 | + /// any well-formed actor URL. | |
| 316 | + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 317 | + use crate::models::fediverse_blocked_domain; | |
| 318 | + use rustwarden_core::models::app_settings; | |
| 319 | + let now = Utc::now(); | |
| 320 | + mock.append_query_results([vec![app_settings::Model { | |
| 321 | + id: Uuid::new_v4(), | |
| 322 | + key: "fediverse_enabled".to_string(), | |
| 323 | + value: "true".to_string(), | |
| 324 | + description: None, | |
| 325 | + updated_by: None, | |
| 326 | + updated_at: now, | |
| 327 | + }]]) | |
| 328 | + .append_query_results([vec![app_settings::Model { | |
| 329 | + id: Uuid::new_v4(), | |
| 330 | + key: "fediverse_trust_mode".to_string(), | |
| 331 | + value: "open_with_blocklist".to_string(), | |
| 332 | + description: None, | |
| 333 | + updated_by: None, | |
| 334 | + updated_at: now, | |
| 335 | + }]]) | |
| 336 | + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 337 | + } | |
| 338 | + | |
| 339 | + fn test_auth_config() -> AuthConfig { | |
| 340 | + AuthConfig { | |
| 341 | + jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 342 | + .to_string(), | |
| 343 | + jwt_expiration_minutes: 15, | |
| 344 | + jwt_issuer: "rustwarden-test".to_string(), | |
| 345 | + refresh_token_expiration_days: 7, | |
| 346 | + remember_me_expiration_days: 30, | |
| 347 | + session_timeout_minutes: 30, | |
| 348 | + session_backend: rustwarden_core::config::auth::SessionBackend::Memory, | |
| 349 | + redis_url: None, | |
| 350 | + rate_limit_login_per_minute: 5, | |
| 351 | + rate_limit_refresh_per_minute: 10, | |
| 352 | + rate_limit_general_per_minute: 100, | |
| 353 | + cookie_secure: false, | |
| 354 | + cookie_same_site: "Strict".to_string(), | |
| 355 | + } | |
| 356 | + } | |
| 357 | + | |
| 358 | + struct AlwaysAccept; | |
| 359 | + | |
| 360 | + #[async_trait] | |
| 361 | + impl SignatureVerifier for AlwaysAccept { | |
| 362 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 363 | + Ok(()) | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + fn build_router(db: DatabaseConnection) -> Router { | |
| 368 | + let state = AuthFlowState { | |
| 369 | + auth_config: Arc::new(test_auth_config()), | |
| 370 | + verifier: Arc::new(AlwaysAccept), | |
| 371 | + challenge_ttl: Duration::from_secs(300), | |
| 372 | + }; | |
| 373 | + router(state) | |
| 374 | + .layer(Extension(db)) | |
| 375 | + .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) | |
| 376 | + } | |
| 377 | + | |
| 378 | + fn fresh_challenge(id: Uuid) -> fediverse_auth_challenge::Model { | |
| 379 | + let now = Utc::now(); | |
| 380 | + fediverse_auth_challenge::Model { | |
| 381 | + id, | |
| 382 | + nonce: "nonce-xxx".into(), | |
| 383 | + handle: "alice@a.tld".into(), | |
| 384 | + domain: "a.tld".into(), | |
| 385 | + requested_remote_id: None, | |
| 386 | + status: ChallengeStatus::Pending.as_str().into(), | |
| 387 | + redirect_url: None, | |
| 388 | + created_at: now, | |
| 389 | + expires_at: now + ChronoDuration::minutes(5), | |
| 390 | + consumed_at: None, | |
| 391 | + } | |
| 392 | + } | |
| 393 | + | |
| 394 | + fn req_post(uri: &str, body: &str) -> Request<Body> { | |
| 395 | + Request::builder() | |
| 396 | + .uri(uri) | |
| 397 | + .method("POST") | |
| 398 | + .header("content-type", "application/json") | |
| 399 | + .body(Body::from(body.to_string())) | |
| 400 | + .unwrap() | |
| 401 | + } | |
| 402 | + | |
| 403 | + fn req_get(uri: &str) -> Request<Body> { | |
| 404 | + Request::builder() | |
| 405 | + .uri(uri) | |
| 406 | + .method("GET") | |
| 407 | + .body(Body::empty()) | |
| 408 | + .unwrap() | |
| 409 | + } | |
| 410 | + | |
| 411 | + // ─── initiate ──────────────────────────────────────────────────── | |
| 412 | + | |
| 413 | + #[tokio::test] | |
| 414 | + async fn initiate_returns_201_with_challenge_id() { | |
| 415 | + let row = fresh_challenge(Uuid::new_v4()); | |
| 416 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 417 | + .append_query_results([vec![row]]) | |
| 418 | + .into_connection(); | |
| 419 | + let body = serde_json::json!({ | |
| 420 | + "handle": "alice@a.tld", | |
| 421 | + }) | |
| 422 | + .to_string(); | |
| 423 | + let resp = build_router(db) | |
| 424 | + .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) | |
| 425 | + .await | |
| 426 | + .unwrap(); | |
| 427 | + assert_eq!(resp.status(), StatusCode::CREATED); | |
| 428 | + } | |
| 429 | + | |
| 430 | + #[tokio::test] | |
| 431 | + async fn initiate_returns_400_for_invalid_handle() { | |
| 432 | + let db: DatabaseConnection = | |
| 433 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 434 | + let body = serde_json::json!({ | |
| 435 | + "handle": "no-at-sign", | |
| 436 | + }) | |
| 437 | + .to_string(); | |
| 438 | + let resp = build_router(db) | |
| 439 | + .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body)) | |
| 440 | + .await | |
| 441 | + .unwrap(); | |
| 442 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 443 | + } | |
| 444 | + | |
| 445 | + // ─── status ────────────────────────────────────────────────────── | |
| 446 | + | |
| 447 | + #[tokio::test] | |
| 448 | + async fn status_returns_pending_for_fresh_challenge() { | |
| 449 | + let id = Uuid::new_v4(); | |
| 450 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 451 | + .append_query_results([vec![fresh_challenge(id)]]) | |
| 452 | + .into_connection(); | |
| 453 | + let resp = build_router(db) | |
| 454 | + .oneshot(req_get(&format!( | |
| 455 | + "/api/v1/auth/login/fediverse/status/{id}" | |
| 456 | + ))) | |
| 457 | + .await | |
| 458 | + .unwrap(); | |
| 459 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 460 | + } | |
| 461 | + | |
| 462 | + #[tokio::test] | |
| 463 | + async fn status_returns_404_for_unknown_id() { | |
| 464 | + let id = Uuid::new_v4(); | |
| 465 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 466 | + .append_query_results([Vec::<fediverse_auth_challenge::Model>::new()]) | |
| 467 | + .into_connection(); | |
| 468 | + let resp = build_router(db) | |
| 469 | + .oneshot(req_get(&format!( | |
| 470 | + "/api/v1/auth/login/fediverse/status/{id}" | |
| 471 | + ))) | |
| 472 | + .await | |
| 473 | + .unwrap(); | |
| 474 | + assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 475 | + } | |
| 476 | + | |
| 477 | + // ─── consume ───────────────────────────────────────────────────── | |
| 478 | + | |
| 479 | + #[tokio::test] | |
| 480 | + async fn consume_returns_jwt_for_approved_challenge() { | |
| 481 | + let id = Uuid::new_v4(); | |
| 482 | + let remote_id = Uuid::new_v4(); | |
| 483 | + let mut row = fresh_challenge(id); | |
| 484 | + row.status = ChallengeStatus::Approved.as_str().into(); | |
| 485 | + row.requested_remote_id = Some(remote_id); | |
| 486 | + let mut after = row.clone(); | |
| 487 | + after.status = ChallengeStatus::Consumed.as_str().into(); | |
| 488 | + after.consumed_at = Some(Utc::now()); | |
| 489 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 490 | + .append_query_results([vec![row]]) | |
| 491 | + .append_query_results([vec![after]]) | |
| 492 | + .append_exec_results([MockExecResult { | |
| 493 | + last_insert_id: 0, | |
| 494 | + rows_affected: 1, | |
| 495 | + }]) | |
| 496 | + .into_connection(); | |
| 497 | + let resp = build_router(db) | |
| 498 | + .oneshot(req_post( | |
| 499 | + &format!("/api/v1/auth/login/fediverse/consume/{id}"), | |
| 500 | + "", | |
| 501 | + )) | |
| 502 | + .await | |
| 503 | + .unwrap(); | |
| 504 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 505 | + } | |
| 506 | + | |
| 507 | + #[tokio::test] | |
| 508 | + async fn consume_returns_400_when_pending() { | |
| 509 | + let id = Uuid::new_v4(); | |
| 510 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 511 | + .append_query_results([vec![fresh_challenge(id)]]) | |
| 512 | + .into_connection(); | |
| 513 | + let resp = build_router(db) | |
| 514 | + .oneshot(req_post( | |
| 515 | + &format!("/api/v1/auth/login/fediverse/consume/{id}"), | |
| 516 | + "", | |
| 517 | + )) | |
| 518 | + .await | |
| 519 | + .unwrap(); | |
| 520 | + // Validation error from the service maps to 400 via AppError. | |
| 521 | + assert!( | |
| 522 | + resp.status() == StatusCode::BAD_REQUEST | |
| 523 | + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 524 | + "expected 400/422, got {}", | |
| 525 | + resp.status() | |
| 526 | + ); | |
| 527 | + } | |
| 528 | + | |
| 529 | + // ─── auth_inbox ────────────────────────────────────────────────── | |
| 530 | + | |
| 531 | + #[tokio::test] | |
| 532 | + async fn auth_inbox_returns_400_when_requester_actor_unknown() { | |
| 533 | + // Phase 2 (RFC 0006) enriches `auth_inbox` to persist a | |
| 534 | + // challenge bound to the requester's `remote_identities.id`. | |
| 535 | + // When the row is absent the service returns Validation, which | |
| 536 | + // maps to a 400 BadRequest — NOT a 202. | |
| 537 | + let body = serde_json::json!({ | |
| 538 | + "type": "AuthRequest", | |
| 539 | + "actor": "https://unknown.tld", | |
| 540 | + "target": "alice@a.tld", | |
| 541 | + "nonce": "n", | |
| 542 | + }) | |
| 543 | + .to_string(); | |
| 544 | + let req = Request::builder() | |
| 545 | + .uri("/fediverse/auth/inbox") | |
| 546 | + .method("POST") | |
| 547 | + .header("signature", "keyId=...") | |
| 548 | + .header("content-type", "application/activity+json") | |
| 549 | + .body(Body::from(body)) | |
| 550 | + .unwrap(); | |
| 551 | + // Empty remote_identities cache → persist_inbound_request rejects. | |
| 552 | + // F8 needs permissive trust mock prepended so enforcement passes | |
| 553 | + // and the BadRequest assertion catches the persist_inbound_request | |
| 554 | + // path, not the trust gate. | |
| 555 | + // | |
| 556 | + // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race | |
| 557 | + // condition risk with concurrent build_*_payload tests that read | |
| 558 | + // env via FediverseConfig::from_env(). Whatever value is in env | |
| 559 | + // (or none), the assertion still holds: either the target domain | |
| 560 | + // mismatches and persist_inbound_request returns BadRequest, or | |
| 561 | + // the remote_identities lookup misses and also returns BadRequest. | |
| 562 | + let db: DatabaseConnection = | |
| 563 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 564 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 565 | + .into_connection(); | |
| 566 | + let resp = build_router(db).oneshot(req).await.unwrap(); | |
| 567 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 568 | + } | |
| 569 | + | |
| 570 | + #[tokio::test] | |
| 571 | + async fn auth_inbox_returns_401_when_signature_missing() { | |
| 572 | + let req = Request::builder() | |
| 573 | + .uri("/fediverse/auth/inbox") | |
| 574 | + .method("POST") | |
| 575 | + .body(Body::from("{}")) | |
| 576 | + .unwrap(); | |
| 577 | + let db: DatabaseConnection = | |
| 578 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 579 | + let resp = build_router(db).oneshot(req).await.unwrap(); | |
| 580 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 581 | + } | |
| 582 | + | |
| 583 | + // ─── consent ───────────────────────────────────────────────────── | |
| 584 | + | |
| 585 | + #[tokio::test] | |
| 586 | + async fn consent_approve_marks_challenge_approved() { | |
| 587 | + let id = Uuid::new_v4(); | |
| 588 | + let remote_id = Uuid::new_v4(); | |
| 589 | + let row = fresh_challenge(id); | |
| 590 | + let mut after = row.clone(); | |
| 591 | + after.status = ChallengeStatus::Approved.as_str().into(); | |
| 592 | + after.requested_remote_id = Some(remote_id); | |
| 593 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 594 | + .append_query_results([vec![row]]) | |
| 595 | + .append_query_results([vec![after]]) | |
| 596 | + .append_exec_results([MockExecResult { | |
| 597 | + last_insert_id: 0, | |
| 598 | + rows_affected: 1, | |
| 599 | + }]) | |
| 600 | + .into_connection(); | |
| 601 | + let body = serde_json::json!({ | |
| 602 | + "challenge_id": id, | |
| 603 | + "decision": "approve", | |
| 604 | + "remote_identity_id": remote_id, | |
| 605 | + }) | |
| 606 | + .to_string(); | |
| 607 | + let resp = build_router(db) | |
| 608 | + .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) | |
| 609 | + .await | |
| 610 | + .unwrap(); | |
| 611 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 612 | + } | |
| 613 | + | |
| 614 | + #[tokio::test] | |
| 615 | + async fn consent_reject_marks_challenge_rejected() { | |
| 616 | + let id = Uuid::new_v4(); | |
| 617 | + let row = fresh_challenge(id); | |
| 618 | + let mut after = row.clone(); | |
| 619 | + after.status = ChallengeStatus::Rejected.as_str().into(); | |
| 620 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 621 | + .append_query_results([vec![row]]) | |
| 622 | + .append_query_results([vec![after]]) | |
| 623 | + .append_exec_results([MockExecResult { | |
| 624 | + last_insert_id: 0, | |
| 625 | + rows_affected: 1, | |
| 626 | + }]) | |
| 627 | + .into_connection(); | |
| 628 | + let body = serde_json::json!({ | |
| 629 | + "challenge_id": id, | |
| 630 | + "decision": "reject", | |
| 631 | + }) | |
| 632 | + .to_string(); | |
| 633 | + let resp = build_router(db) | |
| 634 | + .oneshot(req_post("/api/v1/fediverse/auth/consent", &body)) | |
| 635 | + .await | |
| 636 | + .unwrap(); | |
| 637 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 638 | + } | |
| 639 | +} |
A
crates/rustwarden-fediverse/src/config.rs
+490
-0
@@ -0,0 +1,490 @@
| 1 | +//! Runtime configuration for ActivityPub identity federation (RFC 0001). | |
| 2 | +//! | |
| 3 | +//! This module is gated behind the `experimental-fediverse` Cargo feature. | |
| 4 | +//! It loads federation parameters from environment variables at startup and | |
| 5 | +//! is consumed by [`crate::app::RustwardenBuilder::with_fediverse`] to wire | |
| 6 | +//! the federation router and outbound worker into the Axum application. | |
| 7 | +//! | |
| 8 | +//! See [`docs/rfcs/0001-fediverse-config-and-builder.md`](https://github.com/EBO/rustwarden/blob/main/docs/rfcs/0001-fediverse-config-and-builder.md) | |
| 9 | +//! for the full specification. | |
| 10 | + | |
| 11 | +use std::time::Duration; | |
| 12 | +use url::Url; | |
| 13 | + | |
| 14 | +/// Runtime configuration for ActivityPub identity federation. | |
| 15 | +/// | |
| 16 | +/// Built from environment variables via [`FediverseConfig::from_env`]. Validated | |
| 17 | +/// at construction: missing or placeholder domains, invalid URLs, and HTTP base | |
| 18 | +/// URLs in production are rejected eagerly. | |
| 19 | +/// | |
| 20 | +/// # Example | |
| 21 | +/// | |
| 22 | +/// ```rust,ignore | |
| 23 | +/// use rustwarden_core::config::FediverseConfig; | |
| 24 | +/// | |
| 25 | +/// // SAFETY: doctest fixture — values are stable test constants and the | |
| 26 | +/// // doctest binary serialises env access through cargo's test harness. | |
| 27 | +/// unsafe { | |
| 28 | +/// std::env::set_var("FEDIVERSE_DOMAIN", "example.org"); | |
| 29 | +/// } | |
| 30 | +/// | |
| 31 | +/// let config = FediverseConfig::from_env().expect("valid config"); | |
| 32 | +/// assert_eq!(config.domain(), "example.org"); | |
| 33 | +/// assert_eq!(config.base_url().as_str(), "https://example.org/"); | |
| 34 | +/// ``` | |
| 35 | +#[derive(Debug, Clone)] | |
| 36 | +pub struct FediverseConfig { | |
| 37 | + domain: String, | |
| 38 | + base_url: Url, | |
| 39 | + user_agent: String, | |
| 40 | + authorized_fetch: bool, | |
| 41 | + outbound_workers: usize, | |
| 42 | + outbound_max_attempts: u32, | |
| 43 | + outbound_request_timeout: Duration, | |
| 44 | + fetch_actor_cache_ttl: Duration, | |
| 45 | + auth_challenge_ttl: Duration, | |
| 46 | + debug_local_federation: bool, | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// Errors that can occur while loading or validating [`FediverseConfig`]. | |
| 50 | +#[derive(Debug, thiserror::Error)] | |
| 51 | +pub enum FediverseConfigError { | |
| 52 | + /// `FEDIVERSE_DOMAIN` environment variable is missing or empty. | |
| 53 | + #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")] | |
| 54 | + MissingDomain, | |
| 55 | + | |
| 56 | + /// `FEDIVERSE_DOMAIN` value is a known placeholder | |
| 57 | + /// (e.g. `example.com`, `localhost.localdomain`, `your.tld`). | |
| 58 | + /// Refused regardless of debug mode to prevent accidental production use. | |
| 59 | + #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")] | |
| 60 | + PlaceholderDomain(String), | |
| 61 | + | |
| 62 | + /// Computed base URL is not HTTPS while `debug_local_federation` is off. | |
| 63 | + #[error("base URL `{0}` must use HTTPS in production (set FEDIVERSE_DEBUG_LOCAL_FEDERATION=true for local tests)")] | |
| 64 | + InsecureBaseUrl(String), | |
| 65 | + | |
| 66 | + /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host. | |
| 67 | + #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")] | |
| 68 | + InvalidDomain(String, String), | |
| 69 | + | |
| 70 | + /// A numeric environment variable could not be parsed. | |
| 71 | + #[error("environment variable `{name}` has an invalid value `{value}`: {reason}")] | |
| 72 | + InvalidNumeric { | |
| 73 | + /// Name of the offending environment variable. | |
| 74 | + name: &'static str, | |
| 75 | + /// Raw value that failed to parse. | |
| 76 | + value: String, | |
| 77 | + /// Underlying parse error description. | |
| 78 | + reason: String, | |
| 79 | + }, | |
| 80 | +} | |
| 81 | + | |
| 82 | +impl FediverseConfig { | |
| 83 | + /// Load and validate a [`FediverseConfig`] from environment variables. | |
| 84 | + /// | |
| 85 | + /// # Required variables | |
| 86 | + /// | |
| 87 | + /// - `FEDIVERSE_DOMAIN` — federated domain, no scheme, no path | |
| 88 | + /// (e.g. `example.org`). | |
| 89 | + /// | |
| 90 | + /// # Optional variables (defaults shown) | |
| 91 | + /// | |
| 92 | + /// | Variable | Default | | |
| 93 | + /// |---|---| | |
| 94 | + /// | `FEDIVERSE_AUTHORIZED_FETCH` | `false` | | |
| 95 | + /// | `FEDIVERSE_OUTBOUND_WORKERS` | `4` | | |
| 96 | + /// | `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | `8` | | |
| 97 | + /// | `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | `30` | | |
| 98 | + /// | `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | `86400` (24 h) | | |
| 99 | + /// | `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | `300` (5 min) | | |
| 100 | + /// | `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | `false` | | |
| 101 | + /// | |
| 102 | + /// When `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, HTTP base URLs are | |
| 103 | + /// accepted (for local 2-instance integration tests). In production this | |
| 104 | + /// flag must remain `false` and the resulting base URL must be HTTPS. | |
| 105 | + /// | |
| 106 | + /// # Errors | |
| 107 | + /// | |
| 108 | + /// See [`FediverseConfigError`] for the full failure taxonomy. | |
| 109 | + pub fn from_env() -> Result<Self, FediverseConfigError> { | |
| 110 | + let raw_domain = std::env::var("FEDIVERSE_DOMAIN") | |
| 111 | + .ok() | |
| 112 | + .map(|s| s.trim().to_string()) | |
| 113 | + .filter(|s| !s.is_empty()) | |
| 114 | + .ok_or(FediverseConfigError::MissingDomain)?; | |
| 115 | + | |
| 116 | + if is_placeholder_domain(&raw_domain) { | |
| 117 | + return Err(FediverseConfigError::PlaceholderDomain(raw_domain)); | |
| 118 | + } | |
| 119 | + | |
| 120 | + let debug_local_federation = read_bool_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", false)?; | |
| 121 | + | |
| 122 | + // Accept either a bare domain ("example.org") or a fully-qualified URL | |
| 123 | + // ("http://localhost:3000"). The bare form always becomes HTTPS; the | |
| 124 | + // explicit form is only honoured under debug mode. | |
| 125 | + let (scheme, host_part) = if let Some(rest) = raw_domain.strip_prefix("https://") { | |
| 126 | + ("https", rest.trim_end_matches('/').to_string()) | |
| 127 | + } else if let Some(rest) = raw_domain.strip_prefix("http://") { | |
| 128 | + ("http", rest.trim_end_matches('/').to_string()) | |
| 129 | + } else { | |
| 130 | + ("https", raw_domain.trim_end_matches('/').to_string()) | |
| 131 | + }; | |
| 132 | + | |
| 133 | + if scheme == "http" && !debug_local_federation { | |
| 134 | + return Err(FediverseConfigError::InsecureBaseUrl(format!( | |
| 135 | + "http://{host_part}" | |
| 136 | + ))); | |
| 137 | + } | |
| 138 | + | |
| 139 | + let base_url_str = format!("{scheme}://{host_part}/"); | |
| 140 | + let base_url = Url::parse(&base_url_str) | |
| 141 | + .map_err(|e| FediverseConfigError::InvalidDomain(host_part.clone(), e.to_string()))?; | |
| 142 | + | |
| 143 | + let authorized_fetch = read_bool_env("FEDIVERSE_AUTHORIZED_FETCH", false)?; | |
| 144 | + let outbound_workers = read_numeric_env("FEDIVERSE_OUTBOUND_WORKERS", 4)?; | |
| 145 | + let outbound_max_attempts = read_numeric_env("FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", 8)?; | |
| 146 | + let outbound_request_timeout = | |
| 147 | + Duration::from_secs(read_numeric_env("FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", 30)?); | |
| 148 | + let fetch_actor_cache_ttl = Duration::from_secs(read_numeric_env( | |
| 149 | + "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", | |
| 150 | + 86_400, | |
| 151 | + )?); | |
| 152 | + let auth_challenge_ttl = | |
| 153 | + Duration::from_secs(read_numeric_env("FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", 300)?); | |
| 154 | + | |
| 155 | + let user_agent = format!( | |
| 156 | + "rustwarden/{} (+{})", | |
| 157 | + env!("CARGO_PKG_VERSION"), | |
| 158 | + base_url | |
| 159 | + ); | |
| 160 | + | |
| 161 | + Ok(Self { | |
| 162 | + domain: host_part, | |
| 163 | + base_url, | |
| 164 | + user_agent, | |
| 165 | + authorized_fetch, | |
| 166 | + outbound_workers, | |
| 167 | + outbound_max_attempts, | |
| 168 | + outbound_request_timeout, | |
| 169 | + fetch_actor_cache_ttl, | |
| 170 | + auth_challenge_ttl, | |
| 171 | + debug_local_federation, | |
| 172 | + }) | |
| 173 | + } | |
| 174 | + | |
| 175 | + /// Federated domain (no scheme, no trailing slash). Example: `example.org`. | |
| 176 | + pub fn domain(&self) -> &str { | |
| 177 | + &self.domain | |
| 178 | + } | |
| 179 | + | |
| 180 | + /// Canonical base URL with trailing slash. Example: `https://example.org/`. | |
| 181 | + pub fn base_url(&self) -> &Url { | |
| 182 | + &self.base_url | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// HTTP `User-Agent` to send on outbound federation requests. | |
| 186 | + pub fn user_agent(&self) -> &str { | |
| 187 | + &self.user_agent | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// Whether incoming `GET` requests on actor/object endpoints must be | |
| 191 | + /// signed (Mastodon "secure mode" / Authorized Fetch). | |
| 192 | + pub fn authorized_fetch(&self) -> bool { | |
| 193 | + self.authorized_fetch | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Number of concurrent worker tasks delivering outbound activities. | |
| 197 | + pub fn outbound_workers(&self) -> usize { | |
| 198 | + self.outbound_workers | |
| 199 | + } | |
| 200 | + | |
| 201 | + /// Maximum delivery attempts before a job is marked `dead`. | |
| 202 | + pub fn outbound_max_attempts(&self) -> u32 { | |
| 203 | + self.outbound_max_attempts | |
| 204 | + } | |
| 205 | + | |
| 206 | + /// Per-request HTTP timeout for outbound deliveries. | |
| 207 | + pub fn outbound_request_timeout(&self) -> Duration { | |
| 208 | + self.outbound_request_timeout | |
| 209 | + } | |
| 210 | + | |
| 211 | + /// TTL for cached remote actor documents. | |
| 212 | + pub fn fetch_actor_cache_ttl(&self) -> Duration { | |
| 213 | + self.fetch_actor_cache_ttl | |
| 214 | + } | |
| 215 | + | |
| 216 | + /// TTL for federated authentication challenges (cf. Phase 2). | |
| 217 | + pub fn auth_challenge_ttl(&self) -> Duration { | |
| 218 | + self.auth_challenge_ttl | |
| 219 | + } | |
| 220 | + | |
| 221 | + /// When `true`, HTTP base URLs are allowed (local 2-instance tests). | |
| 222 | + pub fn debug_local_federation(&self) -> bool { | |
| 223 | + self.debug_local_federation | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +/// Hostnames that almost certainly indicate the operator forgot to override | |
| 228 | +/// the placeholder (CLI wizard templates, canonical RFC examples). Refused | |
| 229 | +/// regardless of debug mode. | |
| 230 | +/// | |
| 231 | +/// `example.org` is intentionally **not** included: it is a real IANA-owned | |
| 232 | +/// domain commonly used in tests, doctests, and demos for this framework. | |
| 233 | +const PLACEHOLDER_DOMAINS: &[&str] = &[ | |
| 234 | + "example.com", | |
| 235 | + "example.net", | |
| 236 | + "localhost.localdomain", | |
| 237 | + "your.tld", | |
| 238 | + "your-domain.tld", | |
| 239 | + "domain.tld", | |
| 240 | + "change-me.tld", | |
| 241 | +]; | |
| 242 | + | |
| 243 | +fn is_placeholder_domain(raw: &str) -> bool { | |
| 244 | + let lowered = raw.to_ascii_lowercase(); | |
| 245 | + let stripped = lowered | |
| 246 | + .trim_start_matches("http://") | |
| 247 | + .trim_start_matches("https://") | |
| 248 | + .trim_end_matches('/'); | |
| 249 | + PLACEHOLDER_DOMAINS.contains(&stripped) | |
| 250 | +} | |
| 251 | + | |
| 252 | +fn read_bool_env(key: &'static str, default: bool) -> Result<bool, FediverseConfigError> { | |
| 253 | + match std::env::var(key) { | |
| 254 | + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { | |
| 255 | + "" => Ok(default), | |
| 256 | + "true" | "1" | "yes" | "on" => Ok(true), | |
| 257 | + "false" | "0" | "no" | "off" => Ok(false), | |
| 258 | + other => Err(FediverseConfigError::InvalidNumeric { | |
| 259 | + name: key, | |
| 260 | + value: other.to_string(), | |
| 261 | + reason: "expected boolean (true/false/1/0/yes/no/on/off)".into(), | |
| 262 | + }), | |
| 263 | + }, | |
| 264 | + Err(_) => Ok(default), | |
| 265 | + } | |
| 266 | +} | |
| 267 | + | |
| 268 | +fn read_numeric_env<T>(key: &'static str, default: T) -> Result<T, FediverseConfigError> | |
| 269 | +where | |
| 270 | + T: std::str::FromStr, | |
| 271 | + T::Err: std::fmt::Display, | |
| 272 | +{ | |
| 273 | + match std::env::var(key) { | |
| 274 | + Ok(value) => { | |
| 275 | + let trimmed = value.trim(); | |
| 276 | + if trimmed.is_empty() { | |
| 277 | + return Ok(default); | |
| 278 | + } | |
| 279 | + trimmed | |
| 280 | + .parse::<T>() | |
| 281 | + .map_err(|e| FediverseConfigError::InvalidNumeric { | |
| 282 | + name: key, | |
| 283 | + value: trimmed.to_string(), | |
| 284 | + reason: e.to_string(), | |
| 285 | + }) | |
| 286 | + } | |
| 287 | + Err(_) => Ok(default), | |
| 288 | + } | |
| 289 | +} | |
| 290 | + | |
| 291 | +#[cfg(test)] | |
| 292 | +impl FediverseConfig { | |
| 293 | + /// Build a deterministic config for unit tests, bypassing env-var loading. | |
| 294 | + /// Always HTTPS (`https://{domain}/`), all numeric defaults applied, | |
| 295 | + /// `debug_local_federation = false`. | |
| 296 | + pub(crate) fn for_tests(domain: &str) -> Self { | |
| 297 | + let base_url = Url::parse(&format!("https://{domain}/")) | |
| 298 | + .expect("test domain must produce a valid URL"); | |
| 299 | + Self { | |
| 300 | + domain: domain.to_string(), | |
| 301 | + user_agent: format!("rustwarden/test (+{base_url})"), | |
| 302 | + base_url, | |
| 303 | + authorized_fetch: false, | |
| 304 | + outbound_workers: 4, | |
| 305 | + outbound_max_attempts: 8, | |
| 306 | + outbound_request_timeout: Duration::from_secs(30), | |
| 307 | + fetch_actor_cache_ttl: Duration::from_secs(86_400), | |
| 308 | + auth_challenge_ttl: Duration::from_secs(300), | |
| 309 | + debug_local_federation: false, | |
| 310 | + } | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 314 | +#[cfg(test)] | |
| 315 | +mod tests { | |
| 316 | + use super::*; | |
| 317 | + use std::sync::Mutex; | |
| 318 | + | |
| 319 | + /// Serialises env-var mutations across tests in this module. Each test | |
| 320 | + /// holds the lock for its full duration and clears the FEDIVERSE_* keyspace | |
| 321 | + /// before setting fixtures, making tests order-independent. | |
| 322 | + static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 323 | + | |
| 324 | + /// Reset every FEDIVERSE_* environment variable so the next test starts | |
| 325 | + /// from a known empty state. | |
| 326 | + fn clear_fediverse_env() { | |
| 327 | + for key in [ | |
| 328 | + "FEDIVERSE_DOMAIN", | |
| 329 | + "FEDIVERSE_AUTHORIZED_FETCH", | |
| 330 | + "FEDIVERSE_OUTBOUND_WORKERS", | |
| 331 | + "FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", | |
| 332 | + "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", | |
| 333 | + "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS", | |
| 334 | + "FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", | |
| 335 | + "FEDIVERSE_DEBUG_LOCAL_FEDERATION", | |
| 336 | + ] { | |
| 337 | + // SAFETY: tests in this module are serialised through ENV_LOCK, | |
| 338 | + // and the helper runs before any code that reads these vars in the | |
| 339 | + // same test. No other thread observes the partial state. | |
| 340 | + unsafe { | |
| 341 | + std::env::remove_var(key); | |
| 342 | + } | |
| 343 | + } | |
| 344 | + } | |
| 345 | + | |
| 346 | + /// Set an environment variable for the duration of the surrounding test. | |
| 347 | + fn set_env(key: &str, value: &str) { | |
| 348 | + // SAFETY: same rationale as `clear_fediverse_env` — serialised via | |
| 349 | + // ENV_LOCK in the calling test. | |
| 350 | + unsafe { | |
| 351 | + std::env::set_var(key, value); | |
| 352 | + } | |
| 353 | + } | |
| 354 | + | |
| 355 | + // ─── Test 1 — Happy path ──────────────────────────────────────────── | |
| 356 | + | |
| 357 | + #[test] | |
| 358 | + fn from_env_returns_valid_config_with_required_var_only() { | |
| 359 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 360 | + clear_fediverse_env(); | |
| 361 | + set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 362 | + | |
| 363 | + let config = FediverseConfig::from_env().expect("valid config"); | |
| 364 | + assert_eq!(config.domain(), "example.org"); | |
| 365 | + assert_eq!(config.base_url().as_str(), "https://example.org/"); | |
| 366 | + assert!(!config.authorized_fetch()); | |
| 367 | + assert!(!config.debug_local_federation()); | |
| 368 | + } | |
| 369 | + | |
| 370 | + // ─── Test 2 — Missing domain ──────────────────────────────────────── | |
| 371 | + | |
| 372 | + #[test] | |
| 373 | + fn from_env_returns_missing_domain_when_var_unset() { | |
| 374 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 375 | + clear_fediverse_env(); | |
| 376 | + | |
| 377 | + let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 378 | + assert!( | |
| 379 | + matches!(err, FediverseConfigError::MissingDomain), | |
| 380 | + "got {err:?}" | |
| 381 | + ); | |
| 382 | + } | |
| 383 | + | |
| 384 | + // ─── Test 3 — Placeholder domains ─────────────────────────────────── | |
| 385 | + | |
| 386 | + #[test] | |
| 387 | + fn from_env_returns_placeholder_error_for_known_placeholders() { | |
| 388 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 389 | + for placeholder in ["example.com", "localhost.localdomain", "your.tld"] { | |
| 390 | + clear_fediverse_env(); | |
| 391 | + set_env("FEDIVERSE_DOMAIN", placeholder); | |
| 392 | + | |
| 393 | + let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 394 | + assert!( | |
| 395 | + matches!(err, FediverseConfigError::PlaceholderDomain(ref d) if d == placeholder), | |
| 396 | + "domain={placeholder} got {err:?}" | |
| 397 | + ); | |
| 398 | + } | |
| 399 | + } | |
| 400 | + | |
| 401 | + // ─── Test 4 — Insecure base URL in production ─────────────────────── | |
| 402 | + | |
| 403 | + #[test] | |
| 404 | + fn from_env_rejects_http_base_url_in_production() { | |
| 405 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 406 | + clear_fediverse_env(); | |
| 407 | + // Domain that yields http:// only in non-debug mode is conceptual; | |
| 408 | + // we trigger the path by feeding a hostname that the parser maps | |
| 409 | + // to HTTPS but with debug=false (default). To force HTTP, the | |
| 410 | + // implementation interprets a leading `http://` scheme in the env | |
| 411 | + // value as opt-in to plain HTTP — only valid under debug mode. | |
| 412 | + set_env("FEDIVERSE_DOMAIN", "http://example.org"); | |
| 413 | + | |
| 414 | + let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 415 | + assert!( | |
| 416 | + matches!(err, FediverseConfigError::InsecureBaseUrl(_)), | |
| 417 | + "got {err:?}" | |
| 418 | + ); | |
| 419 | + } | |
| 420 | + | |
| 421 | + // ─── Test 5 — Debug mode allows HTTP ──────────────────────────────── | |
| 422 | + | |
| 423 | + #[test] | |
| 424 | + fn from_env_allows_http_when_debug_local_federation() { | |
| 425 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 426 | + clear_fediverse_env(); | |
| 427 | + set_env("FEDIVERSE_DOMAIN", "http://localhost:3000"); | |
| 428 | + set_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true"); | |
| 429 | + | |
| 430 | + let config = FediverseConfig::from_env().expect("debug HTTP allowed"); | |
| 431 | + assert!(config.debug_local_federation()); | |
| 432 | + assert_eq!(config.base_url().scheme(), "http"); | |
| 433 | + assert_eq!(config.domain(), "localhost:3000"); | |
| 434 | + } | |
| 435 | + | |
| 436 | + // ─── Test 6 — Numeric var invalid ─────────────────────────────────── | |
| 437 | + | |
| 438 | + #[test] | |
| 439 | + fn from_env_returns_invalid_numeric_for_bad_workers_value() { | |
| 440 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 441 | + clear_fediverse_env(); | |
| 442 | + set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 443 | + set_env("FEDIVERSE_OUTBOUND_WORKERS", "not-a-number"); | |
| 444 | + | |
| 445 | + let err = FediverseConfig::from_env().expect_err("must fail"); | |
| 446 | + assert!( | |
| 447 | + matches!( | |
| 448 | + err, | |
| 449 | + FediverseConfigError::InvalidNumeric { name, .. } | |
| 450 | + if name == "FEDIVERSE_OUTBOUND_WORKERS" | |
| 451 | + ), | |
| 452 | + "got {err:?}" | |
| 453 | + ); | |
| 454 | + } | |
| 455 | + | |
| 456 | + // ─── Test 7 — Defaults ────────────────────────────────────────────── | |
| 457 | + | |
| 458 | + #[test] | |
| 459 | + fn defaults_match_documented_values() { | |
| 460 | + let _guard = ENV_LOCK.lock().expect("env lock poisoned"); | |
| 461 | + clear_fediverse_env(); | |
| 462 | + set_env("FEDIVERSE_DOMAIN", "example.org"); | |
| 463 | + | |
| 464 | + let config = FediverseConfig::from_env().expect("valid config"); | |
| 465 | + assert_eq!(config.outbound_workers(), 4); | |
| 466 | + assert_eq!(config.outbound_max_attempts(), 8); | |
| 467 | + assert_eq!( | |
| 468 | + config.outbound_request_timeout(), | |
| 469 | + Duration::from_secs(30), | |
| 470 | + "outbound_request_timeout default" | |
| 471 | + ); | |
| 472 | + assert_eq!( | |
| 473 | + config.fetch_actor_cache_ttl(), | |
| 474 | + Duration::from_secs(86_400), | |
| 475 | + "fetch_actor_cache_ttl default (24 h)" | |
| 476 | + ); | |
| 477 | + assert_eq!( | |
| 478 | + config.auth_challenge_ttl(), | |
| 479 | + Duration::from_secs(300), | |
| 480 | + "auth_challenge_ttl default (5 min)" | |
| 481 | + ); | |
| 482 | + assert!(!config.authorized_fetch()); | |
| 483 | + assert!(!config.debug_local_federation()); | |
| 484 | + assert!( | |
| 485 | + config.user_agent().contains("rustwarden") || config.user_agent().contains("Rustwarden"), | |
| 486 | + "user_agent={}", | |
| 487 | + config.user_agent() | |
| 488 | + ); | |
| 489 | + } | |
| 490 | +} |
A
crates/rustwarden-fediverse/src/error.rs
+75
-0
@@ -0,0 +1,75 @@
| 1 | +//! Federation-specific error type. | |
| 2 | + | |
| 3 | +use axum::http::StatusCode; | |
| 4 | +use axum::response::{IntoResponse, Response}; | |
| 5 | + | |
| 6 | +use rustwarden_core::error::AppError; | |
| 7 | + | |
| 8 | +/// Errors produced by federation handlers (WebFinger / Actor / NodeInfo). | |
| 9 | +/// | |
| 10 | +/// Mapped to HTTP status codes via [`IntoResponse`]: | |
| 11 | +/// | |
| 12 | +/// | Variant | HTTP status | | |
| 13 | +/// |---|---| | |
| 14 | +/// | `UserNotFound` | 404 | | |
| 15 | +/// | `DomainMismatch` | 404 | | |
| 16 | +/// | `InvalidResource` | 400 | | |
| 17 | +/// | `NotAcceptable` | 406 | | |
| 18 | +/// | `App(_)` | inherited from [`AppError`] | | |
| 19 | +#[derive(Debug, thiserror::Error)] | |
| 20 | +pub enum FediverseError { | |
| 21 | + /// The requested local user does not exist or is disabled. | |
| 22 | + #[error("user not found")] | |
| 23 | + UserNotFound, | |
| 24 | + | |
| 25 | + /// The WebFinger `resource` parameter is malformed. | |
| 26 | + #[error("invalid WebFinger resource: {0}")] | |
| 27 | + InvalidResource(String), | |
| 28 | + | |
| 29 | + /// The WebFinger `resource` targets a domain that is not ours. | |
| 30 | + #[error("WebFinger domain mismatch")] | |
| 31 | + DomainMismatch, | |
| 32 | + | |
| 33 | + /// No representation exists matching the request `Accept` header. | |
| 34 | + #[error("acceptable representation not available")] | |
| 35 | + NotAcceptable, | |
| 36 | + | |
| 37 | + /// RFC 0012: signature missing or rejected by the verifier. | |
| 38 | + #[error("unauthorized: {0}")] | |
| 39 | + Unauthorized(String), | |
| 40 | + | |
| 41 | + /// RFC 0012: request body is invalid (bad JSON or missing required fields). | |
| 42 | + #[error("bad request: {0}")] | |
| 43 | + BadRequest(String), | |
| 44 | + | |
| 45 | + /// RFC 0012: activity type is not implemented by this server. | |
| 46 | + #[error("activity type not implemented: {0}")] | |
| 47 | + Unsupported(String), | |
| 48 | + | |
| 49 | + /// F8 (RFC 0027 / `experimental-fediverse`): the source domain is | |
| 50 | + /// not allowed by this server's trust mode (allowlist miss or | |
| 51 | + /// blocklist hit). 403 is the correct status code: the signature | |
| 52 | + /// itself is valid (would be 401 otherwise), policy refuses. | |
| 53 | + #[error("forbidden: {0}")] | |
| 54 | + Forbidden(String), | |
| 55 | + | |
| 56 | + /// Wraps a generic application error from [`AppError`]. | |
| 57 | + #[error(transparent)] | |
| 58 | + App(#[from] AppError), | |
| 59 | +} | |
| 60 | + | |
| 61 | +impl IntoResponse for FediverseError { | |
| 62 | + fn into_response(self) -> Response { | |
| 63 | + match self { | |
| 64 | + Self::UserNotFound | Self::DomainMismatch => StatusCode::NOT_FOUND.into_response(), | |
| 65 | + Self::InvalidResource(_) | Self::BadRequest(_) => { | |
| 66 | + StatusCode::BAD_REQUEST.into_response() | |
| 67 | + } | |
| 68 | + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE.into_response(), | |
| 69 | + Self::Unauthorized(_) => StatusCode::UNAUTHORIZED.into_response(), | |
| 70 | + Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED.into_response(), | |
| 71 | + Self::Forbidden(_) => StatusCode::FORBIDDEN.into_response(), | |
| 72 | + Self::App(err) => err.into_response(), | |
| 73 | + } | |
| 74 | + } | |
| 75 | +} |
A
crates/rustwarden-fediverse/src/fetcher.rs
+64
-0
@@ -0,0 +1,64 @@
| 1 | +//! HTTP client abstraction used by [`RemoteIdentityService`](crate::services::remote_identity_service::RemoteIdentityService). | |
| 2 | +//! | |
| 3 | +//! The trait [`RemoteFetcher`] decouples the orchestration logic from the | |
| 4 | +//! actual reqwest / signed-HTTP implementation, which keeps unit tests | |
| 5 | +//! deterministic and offline. | |
| 6 | +//! | |
| 7 | +//! The real signed-HTTP implementation arrives with RFC 0010 (authorized | |
| 8 | +//! fetch + outbound worker). For now this module ships only the trait and | |
| 9 | +//! a no-op implementation that always errors, so consumers cannot | |
| 10 | +//! accidentally rely on a non-functional default. | |
| 11 | + | |
| 12 | +use async_trait::async_trait; | |
| 13 | +use url::Url; | |
| 14 | + | |
| 15 | +use super::error::FediverseError; | |
| 16 | +use super::types::{PersonJson, WebFingerResponse}; | |
| 17 | + | |
| 18 | +/// Abstraction over the HTTP I/O performed when resolving remote actors. | |
| 19 | +/// | |
| 20 | +/// Implementors are expected to handle TLS, redirects, and (eventually) | |
| 21 | +/// HTTP signatures. They MUST validate the `Content-Type` of WebFinger | |
| 22 | +/// (`application/jrd+json`) and actor responses (`application/activity+json` | |
| 23 | +/// or `application/ld+json`) before deserialising. | |
| 24 | +#[async_trait] | |
| 25 | +pub trait RemoteFetcher: Send + Sync { | |
| 26 | + /// Resolve a `@user@domain` handle via WebFinger. | |
| 27 | + /// | |
| 28 | + /// Returns [`FediverseError::UserNotFound`] if the remote server | |
| 29 | + /// responded with `404`. Returns [`FediverseError::App`] for any other | |
| 30 | + /// transport or deserialisation error. | |
| 31 | + async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError>; | |
| 32 | + | |
| 33 | + /// Fetch the JSON Person document at the given actor URL. | |
| 34 | + /// | |
| 35 | + /// Returns [`FediverseError::UserNotFound`] for `404`, | |
| 36 | + /// [`FediverseError::App`] for any other failure. | |
| 37 | + async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError>; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Placeholder [`RemoteFetcher`] used until RFC 0010 ships the real | |
| 41 | +/// signed-HTTP implementation. Both methods always return | |
| 42 | +/// [`FediverseError::App`] with a configuration-style error. | |
| 43 | +/// | |
| 44 | +/// Useful as the default when consumers wire their own fetcher (e.g. | |
| 45 | +/// in tests, or in a deployment that has not yet enabled outbound HTTP). | |
| 46 | +#[derive(Debug, Default, Clone)] | |
| 47 | +pub struct UnconfiguredFetcher; | |
| 48 | + | |
| 49 | +#[async_trait] | |
| 50 | +impl RemoteFetcher for UnconfiguredFetcher { | |
| 51 | + async fn webfinger(&self, _handle: &str) -> Result<WebFingerResponse, FediverseError> { | |
| 52 | + Err(FediverseError::App(rustwarden_core::error::AppError::Config( | |
| 53 | + "RemoteFetcher not configured — wire a real implementation via FediverseState" | |
| 54 | + .to_string(), | |
| 55 | + ))) | |
| 56 | + } | |
| 57 | + | |
| 58 | + async fn fetch_actor(&self, _actor_url: &Url) -> Result<PersonJson, FediverseError> { | |
| 59 | + Err(FediverseError::App(rustwarden_core::error::AppError::Config( | |
| 60 | + "RemoteFetcher not configured — wire a real implementation via FediverseState" | |
| 61 | + .to_string(), | |
| 62 | + ))) | |
| 63 | + } | |
| 64 | +} |
A
crates/rustwarden-fediverse/src/http_signatures.rs
+704
-0
@@ -0,0 +1,704 @@
| 1 | +//! Mastodon-compatible HTTP signatures (draft-cavage-12) (RFC 0015). | |
| 2 | +//! | |
| 3 | +//! This module ships the production-ready cryptographic primitives | |
| 4 | +//! that plug into the [`SignatureVerifier`] / [`OutboundDelivery`] | |
| 5 | +//! traits. The current `SignatureVerifier` trait signature only carries | |
| 6 | +//! `signature_header` + `body` + `public_key_pem`; real HTTP-Sig | |
| 7 | +//! verification also needs the request method, path, and `host` / | |
| 8 | +//! `date` headers. Until a follow-up RFC widens the trait, callers | |
| 9 | +//! invoke [`parse_and_verify_cavage`] directly with the full request | |
| 10 | +//! context. | |
| 11 | +//! | |
| 12 | +//! The signing side is integrated end-to-end via [`HttpSignedDelivery`]. | |
| 13 | +//! | |
| 14 | +//! Gated by `experimental-fediverse`. | |
| 15 | + | |
| 16 | +use std::collections::BTreeMap; | |
| 17 | + | |
| 18 | +use base64::Engine; | |
| 19 | +use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey}; | |
| 20 | +use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey}; | |
| 21 | +use rsa::signature::{SignatureEncoding, Signer, Verifier}; | |
| 22 | +use rsa::{RsaPrivateKey, RsaPublicKey}; | |
| 23 | +use sha2::Sha256; | |
| 24 | + | |
| 25 | +use async_trait::async_trait; | |
| 26 | +use sea_orm::DatabaseConnection; | |
| 27 | + | |
| 28 | +use rustwarden_core::error::AppError; | |
| 29 | +use crate::models::fediverse_outbound_queue; | |
| 30 | +use crate::services::actor_key_service::ActorKeyService; | |
| 31 | +use crate::services::fediverse_outbound_service::OutboundDelivery; | |
| 32 | +use crate::services::fediverse_signal_service::{SignatureVerifier, VerifyCtx}; | |
| 33 | + | |
| 34 | +/// Canonical fields extracted from a draft-cavage `Signature` header. | |
| 35 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 36 | +pub struct ParsedSignature { | |
| 37 | + /// Stable identifier of the signing key (e.g. | |
| 38 | + /// `https://a.tld/users/alice#main-key`). | |
| 39 | + pub key_id: String, | |
| 40 | + /// Algorithm. The verifier only accepts `rsa-sha256` for now. | |
| 41 | + pub algorithm: String, | |
| 42 | + /// Ordered list of signed headers (lowercased). | |
| 43 | + pub headers: Vec<String>, | |
| 44 | + /// Decoded signature bytes (raw, **not** base64-encoded). | |
| 45 | + pub signature: Vec<u8>, | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// Parse a `Signature: keyId="…",algorithm="…",headers="…",signature="…"` | |
| 49 | +/// header into a [`ParsedSignature`]. | |
| 50 | +/// | |
| 51 | +/// Returns `Err(AppError::Validation(_))` when a required field is | |
| 52 | +/// missing or the signature is not valid base64. | |
| 53 | +pub fn parse_signature_header(raw: &str) -> Result<ParsedSignature, AppError> { | |
| 54 | + let mut map: BTreeMap<&str, String> = BTreeMap::new(); | |
| 55 | + for part in raw.split(',') { | |
| 56 | + let part = part.trim(); | |
| 57 | + let Some(eq) = part.find('=') else { continue }; | |
| 58 | + let (k, v) = part.split_at(eq); | |
| 59 | + let v = &v[1..]; // skip '=' | |
| 60 | + let v = v.trim(); | |
| 61 | + let unquoted = v.trim_start_matches('"').trim_end_matches('"'); | |
| 62 | + map.insert(k.trim(), unquoted.to_string()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + let key_id = map | |
| 66 | + .remove("keyId") | |
| 67 | + .ok_or_else(|| AppError::Validation("Signature missing keyId".into()))?; | |
| 68 | + let algorithm = map | |
| 69 | + .remove("algorithm") | |
| 70 | + .ok_or_else(|| AppError::Validation("Signature missing algorithm".into()))?; | |
| 71 | + let headers_raw = map | |
| 72 | + .remove("headers") | |
| 73 | + .ok_or_else(|| AppError::Validation("Signature missing headers".into()))?; | |
| 74 | + let signature_b64 = map | |
| 75 | + .remove("signature") | |
| 76 | + .ok_or_else(|| AppError::Validation("Signature missing signature".into()))?; | |
| 77 | + | |
| 78 | + let headers = headers_raw | |
| 79 | + .split_whitespace() | |
| 80 | + .map(|h| h.to_ascii_lowercase()) | |
| 81 | + .collect(); | |
| 82 | + | |
| 83 | + let signature = base64::engine::general_purpose::STANDARD | |
| 84 | + .decode(&signature_b64) | |
| 85 | + .map_err(|e| { | |
| 86 | + AppError::Validation(format!("Signature base64 decode failed: {e}")) | |
| 87 | + })?; | |
| 88 | + | |
| 89 | + Ok(ParsedSignature { | |
| 90 | + key_id, | |
| 91 | + algorithm, | |
| 92 | + headers, | |
| 93 | + signature, | |
| 94 | + }) | |
| 95 | +} | |
| 96 | + | |
| 97 | +/// Build the **signature input string** that draft-cavage signs / verifies. | |
| 98 | +/// | |
| 99 | +/// `signed_headers` is the ordered list from the `Signature: headers="…"` | |
| 100 | +/// field. `request_target` is the lowercased HTTP method + space + | |
| 101 | +/// path-and-query (e.g. `"post /inbox"`). `lookup` returns the canonical | |
| 102 | +/// header value when called with a lowercased header name. | |
| 103 | +pub fn build_signature_input( | |
| 104 | + signed_headers: &[String], | |
| 105 | + request_target: &str, | |
| 106 | + lookup: impl Fn(&str) -> Option<String>, | |
| 107 | +) -> Result<String, AppError> { | |
| 108 | + let mut lines = Vec::with_capacity(signed_headers.len()); | |
| 109 | + for header in signed_headers { | |
| 110 | + let line = if header == "(request-target)" { | |
| 111 | + format!("(request-target): {request_target}") | |
| 112 | + } else { | |
| 113 | + let value = lookup(header).ok_or_else(|| { | |
| 114 | + AppError::Validation(format!( | |
| 115 | + "Signature header references missing field `{header}`", | |
| 116 | + )) | |
| 117 | + })?; | |
| 118 | + format!("{header}: {}", value.trim()) | |
| 119 | + }; | |
| 120 | + lines.push(line); | |
| 121 | + } | |
| 122 | + Ok(lines.join("\n")) | |
| 123 | +} | |
| 124 | + | |
| 125 | +/// Sign an arbitrary byte slice with an RSA-SHA256 PKCS#1 v1.5 | |
| 126 | +/// signature, returning the raw signature bytes. | |
| 127 | +pub fn rsa_sha256_sign(private_pem: &str, input: &[u8]) -> Result<Vec<u8>, AppError> { | |
| 128 | + let private_key = RsaPrivateKey::from_pkcs8_pem(private_pem) | |
| 129 | + .map_err(|e| AppError::Internal(format!("invalid PKCS#8 PEM: {e}")))?; | |
| 130 | + let signing_key = SigningKey::<Sha256>::new(private_key); | |
| 131 | + let signature = signing_key.sign(input); | |
| 132 | + Ok(signature.to_bytes().to_vec()) | |
| 133 | +} | |
| 134 | + | |
| 135 | +/// Verify an RSA-SHA256 PKCS#1 v1.5 signature against `input`. | |
| 136 | +pub fn rsa_sha256_verify( | |
| 137 | + public_pem: &str, | |
| 138 | + input: &[u8], | |
| 139 | + signature: &[u8], | |
| 140 | +) -> Result<(), AppError> { | |
| 141 | + let public_key = RsaPublicKey::from_public_key_pem(public_pem) | |
| 142 | + .map_err(|e| AppError::Internal(format!("invalid SPKI PEM: {e}")))?; | |
| 143 | + let verifying_key = VerifyingKey::<Sha256>::new(public_key); | |
| 144 | + let sig = Signature::try_from(signature) | |
| 145 | + .map_err(|e| AppError::Internal(format!("signature shape: {e}")))?; | |
| 146 | + verifying_key | |
| 147 | + .verify(input, &sig) | |
| 148 | + .map_err(|_| AppError::Internal("HTTP signature mismatch".into()))?; | |
| 149 | + Ok(()) | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// Higher-level helper: parse + verify a draft-cavage Signature header | |
| 153 | +/// against the given request context. Use this in inbox handlers that | |
| 154 | +/// have access to the method, path, headers and body. | |
| 155 | +pub fn parse_and_verify_cavage( | |
| 156 | + signature_header: &str, | |
| 157 | + request_target: &str, | |
| 158 | + body: &[u8], | |
| 159 | + public_pem: &str, | |
| 160 | + headers: impl Fn(&str) -> Option<String>, | |
| 161 | +) -> Result<(), AppError> { | |
| 162 | + let parsed = parse_signature_header(signature_header)?; | |
| 163 | + if parsed.algorithm != "rsa-sha256" { | |
| 164 | + return Err(AppError::Validation(format!( | |
| 165 | + "unsupported algorithm: {}", | |
| 166 | + parsed.algorithm, | |
| 167 | + ))); | |
| 168 | + } | |
| 169 | + let input = build_signature_input(&parsed.headers, request_target, headers)?; | |
| 170 | + let _ = body; // body is hashed via the `digest` header by Mastodon when present. | |
| 171 | + rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature) | |
| 172 | +} | |
| 173 | + | |
| 174 | +/// Production-grade implementation of [`SignatureVerifier`] (RFC 0016 | |
| 175 | +/// + RFC 0017). | |
| 176 | +/// | |
| 177 | +/// Reads the `Signature` header, builds the canonical input string, | |
| 178 | +/// and validates the RSA-SHA256 signature. When `ctx.public_key_pem` | |
| 179 | +/// is empty: | |
| 180 | +/// | |
| 181 | +/// - if the verifier was built with [`Self::with_resolver`], it looks | |
| 182 | +/// up the actor in `remote_identities` (RFC 0003) and uses the | |
| 183 | +/// cached PEM ; | |
| 184 | +/// - otherwise it rejects with `Validation`. | |
| 185 | +pub struct HttpSigVerifier { | |
| 186 | + resolver_db: Option<DatabaseConnection>, | |
| 187 | +} | |
| 188 | + | |
| 189 | +impl HttpSigVerifier { | |
| 190 | + /// Build a verifier that does **not** auto-resolve the public | |
| 191 | + /// key. Callers must populate `ctx.public_key_pem` themselves. | |
| 192 | + pub fn new() -> Self { | |
| 193 | + Self { resolver_db: None } | |
| 194 | + } | |
| 195 | + | |
| 196 | + /// Build a verifier that auto-resolves the public key from the | |
| 197 | + /// `remote_identities` cache (RFC 0003) when `ctx.public_key_pem` | |
| 198 | + /// is empty (RFC 0017). | |
| 199 | + pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 200 | + Self { | |
| 201 | + resolver_db: Some(db), | |
| 202 | + } | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +impl Default for HttpSigVerifier { | |
| 207 | + fn default() -> Self { | |
| 208 | + Self::new() | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +#[async_trait] | |
| 213 | +impl SignatureVerifier for HttpSigVerifier { | |
| 214 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 215 | + let signature_header = ctx | |
| 216 | + .headers | |
| 217 | + .get("signature") | |
| 218 | + .and_then(|v| v.to_str().ok()) | |
| 219 | + .ok_or_else(|| AppError::Validation("missing Signature header".into()))?; | |
| 220 | + | |
| 221 | + let resolved_pem; | |
| 222 | + let public_key_pem: &str = if !ctx.public_key_pem.is_empty() { | |
| 223 | + ctx.public_key_pem | |
| 224 | + } else if let Some(db) = self.resolver_db.as_ref() { | |
| 225 | + use crate::models::remote_identity; | |
| 226 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 227 | + let row = remote_identity::Entity::find() | |
| 228 | + .filter(remote_identity::Column::ApId.eq(ctx.actor_ap_id)) | |
| 229 | + .one(db) | |
| 230 | + .await? | |
| 231 | + .ok_or_else(|| { | |
| 232 | + AppError::Validation(format!( | |
| 233 | + "actor not in cache: {}", | |
| 234 | + ctx.actor_ap_id, | |
| 235 | + )) | |
| 236 | + })?; | |
| 237 | + resolved_pem = row.public_key_pem; | |
| 238 | + &resolved_pem | |
| 239 | + } else { | |
| 240 | + return Err(AppError::Validation( | |
| 241 | + "public key not resolved for actor".into(), | |
| 242 | + )); | |
| 243 | + }; | |
| 244 | + | |
| 245 | + let request_target = format!("{} {}", ctx.method, ctx.path); | |
| 246 | + parse_and_verify_cavage(signature_header, &request_target, ctx.body, public_key_pem, |h| { | |
| 247 | + ctx.headers.get(h).and_then(|v| v.to_str().ok().map(str::to_string)) | |
| 248 | + }) | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +/// Production-grade implementation of [`OutboundDelivery`] (RFC 0016). | |
| 253 | +/// | |
| 254 | +/// Loads the local actor's RSA key (RFC 0005), signs the | |
| 255 | +/// `(request-target) host date` input string, and POSTs the activity | |
| 256 | +/// body to the remote inbox. | |
| 257 | +pub struct HttpSignedDelivery { | |
| 258 | + db: DatabaseConnection, | |
| 259 | + http: reqwest::Client, | |
| 260 | +} | |
| 261 | + | |
| 262 | +impl HttpSignedDelivery { | |
| 263 | + /// Build a delivery handler around an existing SeaORM connection. | |
| 264 | + /// Uses a default `reqwest::Client` (TLS via `native-tls`). | |
| 265 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 266 | + Self { | |
| 267 | + db, | |
| 268 | + http: reqwest::Client::new(), | |
| 269 | + } | |
| 270 | + } | |
| 271 | +} | |
| 272 | + | |
| 273 | +#[async_trait] | |
| 274 | +impl OutboundDelivery for HttpSignedDelivery { | |
| 275 | + async fn deliver( | |
| 276 | + &self, | |
| 277 | + item: &fediverse_outbound_queue::Model, | |
| 278 | + ) -> Result<(), AppError> { | |
| 279 | + let actor_user_id = item.actor_user_id.ok_or_else(|| { | |
| 280 | + AppError::Config("HttpSignedDelivery requires actor_user_id".into()) | |
| 281 | + })?; | |
| 282 | + | |
| 283 | + let material = ActorKeyService::ensure_keys(&self.db, actor_user_id).await?; | |
| 284 | + | |
| 285 | + let url = url::Url::parse(&item.target_inbox).map_err(|e| { | |
| 286 | + AppError::Internal(format!("invalid target_inbox URL: {e}")) | |
| 287 | + })?; | |
| 288 | + let host = url | |
| 289 | + .host_str() | |
| 290 | + .ok_or_else(|| { | |
| 291 | + AppError::Internal("target_inbox URL has no host".to_string()) | |
| 292 | + })? | |
| 293 | + .to_string(); | |
| 294 | + let path = url.path().to_string(); | |
| 295 | + | |
| 296 | + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 297 | + let signed_headers = vec![ | |
| 298 | + "(request-target)".to_string(), | |
| 299 | + "host".to_string(), | |
| 300 | + "date".to_string(), | |
| 301 | + ]; | |
| 302 | + let request_target = format!("post {path}"); | |
| 303 | + let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 304 | + "host" => Some(host.clone()), | |
| 305 | + "date" => Some(date.clone()), | |
| 306 | + _ => None, | |
| 307 | + })?; | |
| 308 | + | |
| 309 | + let signature_bytes = rsa_sha256_sign(&material.private_pem, input.as_bytes())?; | |
| 310 | + let signature_b64 = | |
| 311 | + base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 312 | + let signature_header = format!( | |
| 313 | + r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 314 | + item.target_inbox.trim_end_matches('/'), | |
| 315 | + ); | |
| 316 | + | |
| 317 | + let response = self | |
| 318 | + .http | |
| 319 | + .post(&item.target_inbox) | |
| 320 | + .header("Date", date) | |
| 321 | + .header("Host", host) | |
| 322 | + .header("Signature", signature_header) | |
| 323 | + .header("Content-Type", "application/activity+json") | |
| 324 | + .body(item.payload_json.clone()) | |
| 325 | + .send() | |
| 326 | + .await | |
| 327 | + .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 328 | + | |
| 329 | + let status = response.status(); | |
| 330 | + if status.is_success() { | |
| 331 | + Ok(()) | |
| 332 | + } else { | |
| 333 | + Err(AppError::Internal(format!( | |
| 334 | + "remote inbox responded {status}", | |
| 335 | + ))) | |
| 336 | + } | |
| 337 | + } | |
| 338 | +} | |
| 339 | + | |
| 340 | +#[cfg(test)] | |
| 341 | +mod tests { | |
| 342 | + use super::*; | |
| 343 | + use crate::services::actor_key_service::ActorKeyService; | |
| 344 | + | |
| 345 | + fn fresh_keypair() -> (String, String) { | |
| 346 | + // Generate once per test for honest end-to-end coverage. | |
| 347 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 348 | + (material.public_pem, material.private_pem) | |
| 349 | + } | |
| 350 | + | |
| 351 | + fn b64(bytes: &[u8]) -> String { | |
| 352 | + base64::engine::general_purpose::STANDARD.encode(bytes) | |
| 353 | + } | |
| 354 | + | |
| 355 | + #[test] | |
| 356 | + fn parse_signature_header_extracts_keyid_algorithm_headers_signature() { | |
| 357 | + let raw = r#"keyId="https://a.tld/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="dGVzdA==""#; | |
| 358 | + let parsed = parse_signature_header(raw).expect("parse ok"); | |
| 359 | + assert_eq!(parsed.key_id, "https://a.tld/users/alice#main-key"); | |
| 360 | + assert_eq!(parsed.algorithm, "rsa-sha256"); | |
| 361 | + assert_eq!(parsed.headers, vec!["(request-target)", "host", "date"]); | |
| 362 | + assert_eq!(parsed.signature, b"test"); | |
| 363 | + } | |
| 364 | + | |
| 365 | + #[test] | |
| 366 | + fn parse_signature_header_rejects_missing_required_fields() { | |
| 367 | + let raw = r#"algorithm="rsa-sha256",headers="host","#; | |
| 368 | + let err = parse_signature_header(raw).expect_err("must fail"); | |
| 369 | + assert!(matches!(err, AppError::Validation(_))); | |
| 370 | + } | |
| 371 | + | |
| 372 | + #[test] | |
| 373 | + fn build_signature_input_uses_lowercase_header_names() { | |
| 374 | + let signed = vec!["host".to_string(), "date".to_string()]; | |
| 375 | + let input = build_signature_input(&signed, "post /inbox", |h| match h { | |
| 376 | + "host" => Some("b.tld".into()), | |
| 377 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 378 | + _ => None, | |
| 379 | + }) | |
| 380 | + .unwrap(); | |
| 381 | + assert!(input.contains("host: b.tld")); | |
| 382 | + assert!(input.contains("date: Mon, 10 May 2026 12:00:00 GMT")); | |
| 383 | + } | |
| 384 | + | |
| 385 | + #[test] | |
| 386 | + fn build_signature_input_handles_request_target() { | |
| 387 | + let signed = vec!["(request-target)".to_string()]; | |
| 388 | + let input = build_signature_input(&signed, "post /inbox", |_| None).unwrap(); | |
| 389 | + assert_eq!(input, "(request-target): post /inbox"); | |
| 390 | + } | |
| 391 | + | |
| 392 | + #[test] | |
| 393 | + fn build_signature_input_errors_on_missing_field() { | |
| 394 | + let signed = vec!["digest".to_string()]; | |
| 395 | + let err = build_signature_input(&signed, "post /inbox", |_| None).expect_err(""); | |
| 396 | + assert!(matches!(err, AppError::Validation(_))); | |
| 397 | + } | |
| 398 | + | |
| 399 | + #[test] | |
| 400 | + fn sign_then_verify_round_trip() { | |
| 401 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 402 | + let input = b"(request-target): post /inbox\nhost: b.tld\ndate: x"; | |
| 403 | + let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 404 | + rsa_sha256_verify(&pub_pem, input, &sig).expect("verify ok"); | |
| 405 | + } | |
| 406 | + | |
| 407 | + #[test] | |
| 408 | + fn verify_rejects_tampered_signature() { | |
| 409 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 410 | + let input = b"hello world"; | |
| 411 | + let mut sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 412 | + sig[0] ^= 0xff; | |
| 413 | + let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err("must reject"); | |
| 414 | + assert!(matches!(err, AppError::Internal(_))); | |
| 415 | + } | |
| 416 | + | |
| 417 | + #[test] | |
| 418 | + fn verify_rejects_wrong_public_key() { | |
| 419 | + let (_other_pub, priv_pem) = fresh_keypair(); | |
| 420 | + let (pub_pem, _) = fresh_keypair(); | |
| 421 | + let input = b"hello"; | |
| 422 | + let sig = rsa_sha256_sign(&priv_pem, input).unwrap(); | |
| 423 | + let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err(""); | |
| 424 | + assert!(matches!(err, AppError::Internal(_))); | |
| 425 | + } | |
| 426 | + | |
| 427 | + #[test] | |
| 428 | + fn parse_and_verify_cavage_accepts_well_formed_signature() { | |
| 429 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 430 | + let signed_headers = vec![ | |
| 431 | + "(request-target)".to_string(), | |
| 432 | + "host".to_string(), | |
| 433 | + "date".to_string(), | |
| 434 | + ]; | |
| 435 | + let input = build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 436 | + "host" => Some("b.tld".into()), | |
| 437 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 438 | + _ => None, | |
| 439 | + }) | |
| 440 | + .unwrap(); | |
| 441 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 442 | + let header = format!( | |
| 443 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 444 | + b64(&sig_bytes), | |
| 445 | + ); | |
| 446 | + parse_and_verify_cavage(&header, "post /inbox", b"", &pub_pem, |h| match h { | |
| 447 | + "host" => Some("b.tld".into()), | |
| 448 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 449 | + _ => None, | |
| 450 | + }) | |
| 451 | + .expect("verify ok"); | |
| 452 | + } | |
| 453 | + | |
| 454 | + #[test] | |
| 455 | + fn parse_and_verify_cavage_rejects_unsupported_algorithm() { | |
| 456 | + let header = r#"keyId="x",algorithm="hs2019",headers="host",signature="dGVzdA==""#; | |
| 457 | + let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| None) | |
| 458 | + .expect_err(""); | |
| 459 | + assert!(matches!(err, AppError::Validation(_))); | |
| 460 | + } | |
| 461 | + | |
| 462 | + #[test] | |
| 463 | + fn parse_and_verify_cavage_rejects_invalid_base64() { | |
| 464 | + let header = r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="???""#; | |
| 465 | + let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| Some("h".into())) | |
| 466 | + .expect_err(""); | |
| 467 | + assert!(matches!(err, AppError::Validation(_))); | |
| 468 | + } | |
| 469 | + | |
| 470 | + // ─── RFC 0016 — HttpSigVerifier + HttpSignedDelivery ─────────── | |
| 471 | + | |
| 472 | + #[tokio::test] | |
| 473 | + async fn http_sig_verifier_rejects_when_signature_header_missing() { | |
| 474 | + use axum::http::HeaderMap; | |
| 475 | + let headers = HeaderMap::new(); | |
| 476 | + let ctx = VerifyCtx { | |
| 477 | + actor_ap_id: "x", | |
| 478 | + method: "post", | |
| 479 | + path: "/inbox", | |
| 480 | + headers: &headers, | |
| 481 | + body: b"", | |
| 482 | + public_key_pem: "anything", | |
| 483 | + }; | |
| 484 | + let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 485 | + assert!(matches!(err, AppError::Validation(_))); | |
| 486 | + } | |
| 487 | + | |
| 488 | + #[tokio::test] | |
| 489 | + async fn http_sig_verifier_rejects_when_public_key_pem_empty() { | |
| 490 | + use axum::http::HeaderMap; | |
| 491 | + let mut headers = HeaderMap::new(); | |
| 492 | + headers.insert("signature", "keyId=\"x\"".parse().unwrap()); | |
| 493 | + let ctx = VerifyCtx { | |
| 494 | + actor_ap_id: "x", | |
| 495 | + method: "post", | |
| 496 | + path: "/inbox", | |
| 497 | + headers: &headers, | |
| 498 | + body: b"", | |
| 499 | + public_key_pem: "", | |
| 500 | + }; | |
| 501 | + let err = HttpSigVerifier::new().verify(&ctx).await.expect_err(""); | |
| 502 | + assert!(matches!(err, AppError::Validation(_))); | |
| 503 | + } | |
| 504 | + | |
| 505 | + #[tokio::test] | |
| 506 | + async fn http_sig_verifier_accepts_valid_signature() { | |
| 507 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 508 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 509 | + let signed_headers = vec![ | |
| 510 | + "(request-target)".to_string(), | |
| 511 | + "host".to_string(), | |
| 512 | + "date".to_string(), | |
| 513 | + ]; | |
| 514 | + let input = | |
| 515 | + build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 516 | + "host" => Some("b.tld".into()), | |
| 517 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 518 | + _ => None, | |
| 519 | + }) | |
| 520 | + .unwrap(); | |
| 521 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 522 | + let header_value = format!( | |
| 523 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 524 | + b64(&sig_bytes), | |
| 525 | + ); | |
| 526 | + let mut headers = HeaderMap::new(); | |
| 527 | + headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 528 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 529 | + headers.insert( | |
| 530 | + "date", | |
| 531 | + HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 532 | + ); | |
| 533 | + let body = b""; | |
| 534 | + let ctx = VerifyCtx { | |
| 535 | + actor_ap_id: "x", | |
| 536 | + method: "post", | |
| 537 | + path: "/inbox", | |
| 538 | + headers: &headers, | |
| 539 | + body, | |
| 540 | + public_key_pem: &pub_pem, | |
| 541 | + }; | |
| 542 | + HttpSigVerifier::new() | |
| 543 | + .verify(&ctx) | |
| 544 | + .await | |
| 545 | + .expect("valid signature must pass"); | |
| 546 | + } | |
| 547 | + | |
| 548 | + #[tokio::test] | |
| 549 | + async fn http_sig_verifier_resolves_pem_from_remote_identities_when_empty() { | |
| 550 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 551 | + use chrono::Utc; | |
| 552 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 553 | + use uuid::Uuid; | |
| 554 | + | |
| 555 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 556 | + let signed_headers = vec![ | |
| 557 | + "(request-target)".to_string(), | |
| 558 | + "host".to_string(), | |
| 559 | + "date".to_string(), | |
| 560 | + ]; | |
| 561 | + let input = | |
| 562 | + build_signature_input(&signed_headers, "post /inbox", |h| match h { | |
| 563 | + "host" => Some("b.tld".into()), | |
| 564 | + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()), | |
| 565 | + _ => None, | |
| 566 | + }) | |
| 567 | + .unwrap(); | |
| 568 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 569 | + let header_value = format!( | |
| 570 | + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#, | |
| 571 | + b64(&sig_bytes), | |
| 572 | + ); | |
| 573 | + let mut headers = HeaderMap::new(); | |
| 574 | + headers.insert("signature", HeaderValue::from_str(&header_value).unwrap()); | |
| 575 | + headers.insert("host", HeaderValue::from_static("b.tld")); | |
| 576 | + headers.insert( | |
| 577 | + "date", | |
| 578 | + HeaderValue::from_static("Mon, 10 May 2026 12:00:00 GMT"), | |
| 579 | + ); | |
| 580 | + | |
| 581 | + let now = Utc::now(); | |
| 582 | + let cached = crate::models::remote_identity::Model { | |
| 583 | + id: Uuid::new_v4(), | |
| 584 | + ap_id: "https://a.tld/users/alice".into(), | |
| 585 | + username: "alice".into(), | |
| 586 | + domain: "a.tld".into(), | |
| 587 | + display_name: None, | |
| 588 | + summary_html: None, | |
| 589 | + inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 590 | + shared_inbox_url: None, | |
| 591 | + public_key_pem: pub_pem, | |
| 592 | + icon_url: None, | |
| 593 | + actor_type: "Person".into(), | |
| 594 | + raw_json: None, | |
| 595 | + last_fetched_at: now, | |
| 596 | + disabled_at: None, | |
| 597 | + created_at: now, | |
| 598 | + updated_at: now, | |
| 599 | + }; | |
| 600 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 601 | + .append_query_results([vec![cached]]) | |
| 602 | + .into_connection(); | |
| 603 | + | |
| 604 | + let ctx = VerifyCtx { | |
| 605 | + actor_ap_id: "https://a.tld/users/alice", | |
| 606 | + method: "post", | |
| 607 | + path: "/inbox", | |
| 608 | + headers: &headers, | |
| 609 | + body: b"", | |
| 610 | + public_key_pem: "", // empty → triggers resolver path | |
| 611 | + }; | |
| 612 | + HttpSigVerifier::with_resolver(db) | |
| 613 | + .verify(&ctx) | |
| 614 | + .await | |
| 615 | + .expect("resolver path must accept valid signature"); | |
| 616 | + } | |
| 617 | + | |
| 618 | + #[tokio::test] | |
| 619 | + async fn http_sig_verifier_rejects_when_actor_not_in_cache() { | |
| 620 | + use axum::http::{HeaderMap, HeaderValue}; | |
| 621 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 622 | + | |
| 623 | + let mut headers = HeaderMap::new(); | |
| 624 | + headers.insert( | |
| 625 | + "signature", | |
| 626 | + HeaderValue::from_static(r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="dGVzdA==""#), | |
| 627 | + ); | |
| 628 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 629 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 630 | + .into_connection(); | |
| 631 | + | |
| 632 | + let ctx = VerifyCtx { | |
| 633 | + actor_ap_id: "https://ghost.tld/users/nobody", | |
| 634 | + method: "post", | |
| 635 | + path: "/inbox", | |
| 636 | + headers: &headers, | |
| 637 | + body: b"", | |
| 638 | + public_key_pem: "", | |
| 639 | + }; | |
| 640 | + let err = HttpSigVerifier::with_resolver(db) | |
| 641 | + .verify(&ctx) | |
| 642 | + .await | |
| 643 | + .expect_err("must reject unknown actor"); | |
| 644 | + assert!(matches!(err, AppError::Validation(_))); | |
| 645 | + } | |
| 646 | + | |
| 647 | + #[tokio::test] | |
| 648 | + async fn http_signed_delivery_rejects_when_actor_user_id_none() { | |
| 649 | + use crate::models::fediverse_outbound_queue::Model; | |
| 650 | + use chrono::Utc; | |
| 651 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 652 | + use uuid::Uuid; | |
| 653 | + let db: DatabaseConnection = | |
| 654 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 655 | + let now = Utc::now(); | |
| 656 | + let item = Model { | |
| 657 | + id: Uuid::new_v4(), | |
| 658 | + activity_id: "act".into(), | |
| 659 | + payload_json: "{}".into(), | |
| 660 | + target_inbox: "https://b.tld/inbox".into(), | |
| 661 | + target_domain: "b.tld".into(), | |
| 662 | + status: "pending".into(), | |
| 663 | + attempts: 0, | |
| 664 | + max_attempts: 8, | |
| 665 | + last_error: None, | |
| 666 | + next_retry_at: None, | |
| 667 | + created_at: now, | |
| 668 | + delivered_at: None, | |
| 669 | + actor_user_id: None, | |
| 670 | + }; | |
| 671 | + let delivery = HttpSignedDelivery::new(db); | |
| 672 | + let err = delivery.deliver(&item).await.expect_err("must reject"); | |
| 673 | + assert!(matches!(err, AppError::Config(_))); | |
| 674 | + } | |
| 675 | + | |
| 676 | + #[test] | |
| 677 | + fn parse_and_verify_cavage_rejects_tampered_input() { | |
| 678 | + let (pub_pem, priv_pem) = fresh_keypair(); | |
| 679 | + let signed_headers = vec!["host".to_string()]; | |
| 680 | + let input = build_signature_input(&signed_headers, "post /x", |h| { | |
| 681 | + if h == "host" { | |
| 682 | + Some("b.tld".into()) | |
| 683 | + } else { | |
| 684 | + None | |
| 685 | + } | |
| 686 | + }) | |
| 687 | + .unwrap(); | |
| 688 | + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap(); | |
| 689 | + let header = format!( | |
| 690 | + r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="{}""#, | |
| 691 | + b64(&sig_bytes), | |
| 692 | + ); | |
| 693 | + // Verify against a different host value. | |
| 694 | + let err = parse_and_verify_cavage(&header, "post /x", b"", &pub_pem, |h| { | |
| 695 | + if h == "host" { | |
| 696 | + Some("evil.tld".into()) | |
| 697 | + } else { | |
| 698 | + None | |
| 699 | + } | |
| 700 | + }) | |
| 701 | + .expect_err("must reject tampered host"); | |
| 702 | + assert!(matches!(err, AppError::Internal(_))); | |
| 703 | + } | |
| 704 | +} |
A
crates/rustwarden-fediverse/src/inbox.rs
+440
-0
@@ -0,0 +1,440 @@
| 1 | +//! `POST /inbox` handler — receives ActivityPub lifecycle activities | |
| 2 | +//! and dispatches them to [`crate::services::fediverse_signal_service::FediverseSignalService`] | |
| 3 | +//! (RFC 0012). | |
| 4 | +//! | |
| 5 | +//! The route reads the `Signature` HTTP header, delegates verification | |
| 6 | +//! to the configured [`SignatureVerifier`], then routes by activity | |
| 7 | +//! `type`. Only `Update`, `Delete`, and `Revoke` are supported in this | |
| 8 | +//! slice; the rest get `501 Not Implemented`. | |
| 9 | +//! | |
| 10 | +//! Gated by `experimental-fediverse`. | |
| 11 | + | |
| 12 | +use std::sync::Arc; | |
| 13 | + | |
| 14 | +use axum::{Extension, body::Bytes, http::HeaderMap, http::StatusCode}; | |
| 15 | +use sea_orm::DatabaseConnection; | |
| 16 | +use serde::Deserialize; | |
| 17 | +use uuid::Uuid; | |
| 18 | + | |
| 19 | +use super::error::FediverseError; | |
| 20 | +use crate::services::fediverse_signal_service::{ | |
| 21 | + FediverseSignalService, SignatureVerifier, UpdatedActor, VerifyCtx, | |
| 22 | +}; | |
| 23 | + | |
| 24 | +/// Per-router state injected into the inbox handler. | |
| 25 | +/// | |
| 26 | +/// Cheaply clonable (the verifier is `Arc`-ed). The application | |
| 27 | +/// instantiates this once via `RustwardenBuilder::with_fediverse`. | |
| 28 | +#[derive(Clone)] | |
| 29 | +pub struct InboxState { | |
| 30 | + /// Pluggable signature verifier. Defaults to `AlwaysAcceptVerifier` | |
| 31 | + /// when the consumer does not call | |
| 32 | + /// `RustwardenBuilder::with_signature_verifier`. | |
| 33 | + pub verifier: Arc<dyn SignatureVerifier>, | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Minimal projection of the JSON-LD payload. | |
| 37 | +/// | |
| 38 | +/// We only deserialise the fields the dispatcher needs; the full body | |
| 39 | +/// is forwarded as `serde_json::Value` to the service for audit / | |
| 40 | +/// re-processing. | |
| 41 | +#[derive(Debug, Deserialize)] | |
| 42 | +struct InboxActivity { | |
| 43 | + #[serde(rename = "type")] | |
| 44 | + kind: String, | |
| 45 | + actor: Option<String>, | |
| 46 | + object: Option<serde_json::Value>, | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// `POST /inbox` axum handler. | |
| 50 | +pub async fn handle( | |
| 51 | + Extension(state): Extension<InboxState>, | |
| 52 | + Extension(db): Extension<DatabaseConnection>, | |
| 53 | + headers: HeaderMap, | |
| 54 | + body: Bytes, | |
| 55 | +) -> Result<StatusCode, FediverseError> { | |
| 56 | + // 1. Signature verification (delegated). | |
| 57 | + let signature_header = headers | |
| 58 | + .get("signature") | |
| 59 | + .and_then(|v| v.to_str().ok()) | |
| 60 | + .ok_or_else(|| { | |
| 61 | + FediverseError::Unauthorized("missing Signature header".to_string()) | |
| 62 | + })?; | |
| 63 | + | |
| 64 | + let activity: InboxActivity = serde_json::from_slice(&body) | |
| 65 | + .map_err(|e| FediverseError::BadRequest(format!("invalid JSON: {e}")))?; | |
| 66 | + | |
| 67 | + let actor_ap_id = activity.actor.as_deref().unwrap_or(""); | |
| 68 | + // Touch `signature_header` to keep the early validation in place | |
| 69 | + // (its absence already errors above) — the value is also reachable | |
| 70 | + // via `headers["signature"]` for impls. | |
| 71 | + let _ = signature_header; | |
| 72 | + | |
| 73 | + // F8 / RFC 0027 — trust policy gate. Reject before any verifier work | |
| 74 | + // or downstream effect when the source domain isn't allowed. | |
| 75 | + let source_domain = url::Url::parse(actor_ap_id) | |
| 76 | + .ok() | |
| 77 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 78 | + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?; | |
| 79 | + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone()) | |
| 80 | + .assert_domain_allowed(&source_domain) | |
| 81 | + .await | |
| 82 | + .map_err(|e| FediverseError::Forbidden(e.to_string()))?; | |
| 83 | + | |
| 84 | + state | |
| 85 | + .verifier | |
| 86 | + .verify(&VerifyCtx { | |
| 87 | + actor_ap_id, | |
| 88 | + method: "post", | |
| 89 | + path: "/inbox", | |
| 90 | + headers: &headers, | |
| 91 | + body: &body, | |
| 92 | + public_key_pem: "", | |
| 93 | + }) | |
| 94 | + .await | |
| 95 | + .map_err(|e| FediverseError::Unauthorized(e.to_string()))?; | |
| 96 | + | |
| 97 | + // 2. Dispatch by activity kind. | |
| 98 | + match activity.kind.as_str() { | |
| 99 | + "Update" => dispatch_update(&db, &activity).await?, | |
| 100 | + "Delete" => dispatch_delete(&db, &activity).await?, | |
| 101 | + "Revoke" => dispatch_revoke(&db, &activity).await?, | |
| 102 | + other => { | |
| 103 | + return Err(FediverseError::Unsupported(other.to_string())); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + Ok(StatusCode::ACCEPTED) | |
| 108 | +} | |
| 109 | + | |
| 110 | +async fn dispatch_update( | |
| 111 | + db: &DatabaseConnection, | |
| 112 | + activity: &InboxActivity, | |
| 113 | +) -> Result<(), FediverseError> { | |
| 114 | + let object = activity | |
| 115 | + .object | |
| 116 | + .as_ref() | |
| 117 | + .ok_or_else(|| FediverseError::BadRequest("Update missing `object`".into()))?; | |
| 118 | + | |
| 119 | + let ap_id = object | |
| 120 | + .get("id") | |
| 121 | + .and_then(|v| v.as_str()) | |
| 122 | + .ok_or_else(|| FediverseError::BadRequest("Update.object missing `id`".into()))? | |
| 123 | + .to_string(); | |
| 124 | + | |
| 125 | + let display_name = object | |
| 126 | + .get("name") | |
| 127 | + .and_then(|v| v.as_str()) | |
| 128 | + .map(str::to_string); | |
| 129 | + let summary_html = object | |
| 130 | + .get("summary") | |
| 131 | + .and_then(|v| v.as_str()) | |
| 132 | + .map(str::to_string); | |
| 133 | + let icon_url = object | |
| 134 | + .get("icon") | |
| 135 | + .and_then(|v| v.get("url")) | |
| 136 | + .and_then(|v| v.as_str()) | |
| 137 | + .map(str::to_string); | |
| 138 | + let public_key_pem = object | |
| 139 | + .get("publicKey") | |
| 140 | + .and_then(|v| v.get("publicKeyPem")) | |
| 141 | + .and_then(|v| v.as_str()) | |
| 142 | + .map(str::to_string); | |
| 143 | + | |
| 144 | + FediverseSignalService::apply_update_actor( | |
| 145 | + db, | |
| 146 | + UpdatedActor { | |
| 147 | + ap_id, | |
| 148 | + display_name, | |
| 149 | + summary_html, | |
| 150 | + icon_url, | |
| 151 | + public_key_pem, | |
| 152 | + raw_json: Some(object.clone()), | |
| 153 | + }, | |
| 154 | + ) | |
| 155 | + .await?; | |
| 156 | + Ok(()) | |
| 157 | +} | |
| 158 | + | |
| 159 | +async fn dispatch_delete( | |
| 160 | + db: &DatabaseConnection, | |
| 161 | + activity: &InboxActivity, | |
| 162 | +) -> Result<(), FediverseError> { | |
| 163 | + // AP `Delete{Actor}` carries the actor URL either as a string in | |
| 164 | + // `object` or as `{ "id": "...", "type": "Person" }`. | |
| 165 | + let ap_id = activity | |
| 166 | + .object | |
| 167 | + .as_ref() | |
| 168 | + .and_then(|o| { | |
| 169 | + o.as_str() | |
| 170 | + .map(str::to_string) | |
| 171 | + .or_else(|| o.get("id").and_then(|v| v.as_str()).map(str::to_string)) | |
| 172 | + }) | |
| 173 | + .ok_or_else(|| FediverseError::BadRequest("Delete missing actor reference".into()))?; | |
| 174 | + | |
| 175 | + FediverseSignalService::apply_delete_actor(db, &ap_id).await?; | |
| 176 | + Ok(()) | |
| 177 | +} | |
| 178 | + | |
| 179 | +async fn dispatch_revoke( | |
| 180 | + db: &DatabaseConnection, | |
| 181 | + activity: &InboxActivity, | |
| 182 | +) -> Result<(), FediverseError> { | |
| 183 | + let object = activity | |
| 184 | + .object | |
| 185 | + .as_ref() | |
| 186 | + .ok_or_else(|| FediverseError::BadRequest("Revoke missing `object`".into()))?; | |
| 187 | + | |
| 188 | + let team_id = object | |
| 189 | + .get("team") | |
| 190 | + .and_then(|v| v.as_str()) | |
| 191 | + .and_then(|s| s.rsplit('/').next()) | |
| 192 | + .and_then(|s| Uuid::parse_str(s).ok()) | |
| 193 | + .ok_or_else(|| FediverseError::BadRequest("Revoke.object.team missing/malformed".into()))?; | |
| 194 | + | |
| 195 | + let remote_identity_id = object | |
| 196 | + .get("subject") | |
| 197 | + .and_then(|v| v.as_str()) | |
| 198 | + .and_then(|s| Uuid::parse_str(s).ok()) | |
| 199 | + .ok_or_else(|| FediverseError::BadRequest("Revoke.object.subject missing/malformed".into()))?; | |
| 200 | + | |
| 201 | + FediverseSignalService::apply_revoke_membership(db, team_id, remote_identity_id).await?; | |
| 202 | + Ok(()) | |
| 203 | +} | |
| 204 | + | |
| 205 | +#[cfg(test)] | |
| 206 | +mod tests { | |
| 207 | + use super::*; | |
| 208 | + use rustwarden_core::error::AppError; | |
| 209 | + use async_trait::async_trait; | |
| 210 | + use axum::{Router, body::Body, http::Request, routing::post}; | |
| 211 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 212 | + use tower::ServiceExt; | |
| 213 | + | |
| 214 | + fn make_router(verifier: Arc<dyn SignatureVerifier>, db: DatabaseConnection) -> Router { | |
| 215 | + Router::new() | |
| 216 | + .route("/inbox", post(handle)) | |
| 217 | + .layer(Extension(InboxState { verifier })) | |
| 218 | + .layer(Extension(db)) | |
| 219 | + } | |
| 220 | + | |
| 221 | + fn signed_request(body: &str) -> Request<Body> { | |
| 222 | + Request::builder() | |
| 223 | + .uri("/inbox") | |
| 224 | + .method("POST") | |
| 225 | + .header("signature", "keyId=\"test\",signature=\"...\"") | |
| 226 | + .header("content-type", "application/activity+json") | |
| 227 | + .body(Body::from(body.to_string())) | |
| 228 | + .unwrap() | |
| 229 | + } | |
| 230 | + | |
| 231 | + struct AlwaysAccept; | |
| 232 | + | |
| 233 | + #[async_trait] | |
| 234 | + impl SignatureVerifier for AlwaysAccept { | |
| 235 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 236 | + Ok(()) | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + struct AlwaysReject; | |
| 241 | + | |
| 242 | + #[async_trait] | |
| 243 | + impl SignatureVerifier for AlwaysReject { | |
| 244 | + async fn verify(&self, _ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 245 | + Err(AppError::Internal("nope".into())) | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + /// F8 enforcement helper: seed three mock results so that | |
| 250 | + /// `FediverseTrustService::assert_domain_allowed` answers Ok with | |
| 251 | + /// no real DB. Mode is `open_with_blocklist` + empty blocklist, the | |
| 252 | + /// cheapest permissive shape for unit tests. | |
| 253 | + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase { | |
| 254 | + use crate::models::fediverse_blocked_domain; | |
| 255 | + use rustwarden_core::models::app_settings; | |
| 256 | + let now = chrono::Utc::now(); | |
| 257 | + mock.append_query_results([vec![app_settings::Model { | |
| 258 | + id: Uuid::new_v4(), | |
| 259 | + key: "fediverse_enabled".to_string(), | |
| 260 | + value: "true".to_string(), | |
| 261 | + description: None, | |
| 262 | + updated_by: None, | |
| 263 | + updated_at: now, | |
| 264 | + }]]) | |
| 265 | + .append_query_results([vec![app_settings::Model { | |
| 266 | + id: Uuid::new_v4(), | |
| 267 | + key: "fediverse_trust_mode".to_string(), | |
| 268 | + value: "open_with_blocklist".to_string(), | |
| 269 | + description: None, | |
| 270 | + updated_by: None, | |
| 271 | + updated_at: now, | |
| 272 | + }]]) | |
| 273 | + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 274 | + } | |
| 275 | + | |
| 276 | + fn db_for_update() -> DatabaseConnection { | |
| 277 | + let now = chrono::Utc::now(); | |
| 278 | + let row = crate::models::remote_identity::Model { | |
| 279 | + id: Uuid::new_v4(), | |
| 280 | + ap_id: "https://a.tld/users/alice".into(), | |
| 281 | + username: "alice".into(), | |
| 282 | + domain: "a.tld".into(), | |
| 283 | + display_name: None, | |
| 284 | + summary_html: None, | |
| 285 | + inbox_url: "https://a.tld/users/alice/inbox".into(), | |
| 286 | + shared_inbox_url: None, | |
| 287 | + public_key_pem: String::new(), | |
| 288 | + icon_url: None, | |
| 289 | + actor_type: "Person".into(), | |
| 290 | + raw_json: None, | |
| 291 | + last_fetched_at: now, | |
| 292 | + disabled_at: None, | |
| 293 | + created_at: now, | |
| 294 | + updated_at: now, | |
| 295 | + }; | |
| 296 | + let mut after = row.clone(); | |
| 297 | + after.display_name = Some("Alice".into()); | |
| 298 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 299 | + .append_query_results([vec![row]]) | |
| 300 | + .append_query_results([vec![after]]) | |
| 301 | + .append_exec_results([MockExecResult { | |
| 302 | + last_insert_id: 0, | |
| 303 | + rows_affected: 1, | |
| 304 | + }]) | |
| 305 | + .into_connection() | |
| 306 | + } | |
| 307 | + | |
| 308 | + fn db_for_delete_noop() -> DatabaseConnection { | |
| 309 | + // SELECT returns empty → DeleteOutcome::default(), no UPDATE. | |
| 310 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 311 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 312 | + .into_connection() | |
| 313 | + } | |
| 314 | + | |
| 315 | + fn db_for_revoke_noop() -> DatabaseConnection { | |
| 316 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)) | |
| 317 | + .append_exec_results([MockExecResult { | |
| 318 | + last_insert_id: 0, | |
| 319 | + rows_affected: 0, | |
| 320 | + }]) | |
| 321 | + .into_connection() | |
| 322 | + } | |
| 323 | + | |
| 324 | + #[tokio::test] | |
| 325 | + async fn inbox_returns_202_for_valid_update_actor() { | |
| 326 | + let body = serde_json::json!({ | |
| 327 | + "type": "Update", | |
| 328 | + "actor": "https://a.tld/users/alice", | |
| 329 | + "object": { | |
| 330 | + "id": "https://a.tld/users/alice", | |
| 331 | + "type": "Person", | |
| 332 | + "name": "Alice", | |
| 333 | + } | |
| 334 | + }) | |
| 335 | + .to_string(); | |
| 336 | + let router = make_router(Arc::new(AlwaysAccept), db_for_update()); | |
| 337 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 338 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 339 | + } | |
| 340 | + | |
| 341 | + #[tokio::test] | |
| 342 | + async fn inbox_returns_202_for_valid_delete_actor() { | |
| 343 | + let body = serde_json::json!({ | |
| 344 | + "type": "Delete", | |
| 345 | + "actor": "https://a.tld/users/alice", | |
| 346 | + "object": "https://a.tld/users/alice" | |
| 347 | + }) | |
| 348 | + .to_string(); | |
| 349 | + let router = make_router(Arc::new(AlwaysAccept), db_for_delete_noop()); | |
| 350 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 351 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 352 | + } | |
| 353 | + | |
| 354 | + #[tokio::test] | |
| 355 | + async fn inbox_returns_202_for_valid_revoke_membership() { | |
| 356 | + let team_id = Uuid::new_v4(); | |
| 357 | + let remote_id = Uuid::new_v4(); | |
| 358 | + let body = serde_json::json!({ | |
| 359 | + "type": "Revoke", | |
| 360 | + "actor": "https://a.tld/users/alice", | |
| 361 | + "object": { | |
| 362 | + "type": "TeamMembership", | |
| 363 | + "team": format!("https://b.tld/teams/{team_id}"), | |
| 364 | + "subject": remote_id.to_string(), | |
| 365 | + } | |
| 366 | + }) | |
| 367 | + .to_string(); | |
| 368 | + let router = make_router(Arc::new(AlwaysAccept), db_for_revoke_noop()); | |
| 369 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 370 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 371 | + } | |
| 372 | + | |
| 373 | + #[tokio::test] | |
| 374 | + async fn inbox_returns_401_when_signature_header_missing() { | |
| 375 | + let body = "{\"type\":\"Update\"}"; | |
| 376 | + let req = Request::builder() | |
| 377 | + .uri("/inbox") | |
| 378 | + .method("POST") | |
| 379 | + .body(Body::from(body)) | |
| 380 | + .unwrap(); | |
| 381 | + let router = make_router( | |
| 382 | + Arc::new(AlwaysAccept), | |
| 383 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 384 | + ); | |
| 385 | + let resp = router.oneshot(req).await.unwrap(); | |
| 386 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 387 | + } | |
| 388 | + | |
| 389 | + #[tokio::test] | |
| 390 | + async fn inbox_returns_401_when_verifier_rejects() { | |
| 391 | + let body = serde_json::json!({ | |
| 392 | + "type": "Update", "actor": "https://a.tld/users/alice" | |
| 393 | + }) | |
| 394 | + .to_string(); | |
| 395 | + let router = make_router( | |
| 396 | + Arc::new(AlwaysReject), | |
| 397 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 398 | + ); | |
| 399 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 400 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 401 | + } | |
| 402 | + | |
| 403 | + #[tokio::test] | |
| 404 | + async fn inbox_returns_400_for_invalid_json() { | |
| 405 | + let router = make_router( | |
| 406 | + Arc::new(AlwaysAccept), | |
| 407 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(), | |
| 408 | + ); | |
| 409 | + let resp = router.oneshot(signed_request("not json")).await.unwrap(); | |
| 410 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 411 | + } | |
| 412 | + | |
| 413 | + #[tokio::test] | |
| 414 | + async fn inbox_returns_501_for_unknown_activity_type() { | |
| 415 | + let body = serde_json::json!({ | |
| 416 | + "type": "Like", "actor": "https://a.tld/users/alice" | |
| 417 | + }) | |
| 418 | + .to_string(); | |
| 419 | + let router = make_router( | |
| 420 | + Arc::new(AlwaysAccept), | |
| 421 | + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(), | |
| 422 | + ); | |
| 423 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 424 | + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED); | |
| 425 | + } | |
| 426 | + | |
| 427 | + #[tokio::test] | |
| 428 | + async fn inbox_smoke_test_with_always_accept_verifier() { | |
| 429 | + use crate::services::fediverse_signal_service::AlwaysAcceptVerifier; | |
| 430 | + let body = serde_json::json!({ | |
| 431 | + "type": "Delete", | |
| 432 | + "actor": "https://a.tld/users/ghost", | |
| 433 | + "object": "https://a.tld/users/ghost" | |
| 434 | + }) | |
| 435 | + .to_string(); | |
| 436 | + let router = make_router(Arc::new(AlwaysAcceptVerifier::new()), db_for_delete_noop()); | |
| 437 | + let resp = router.oneshot(signed_request(&body)).await.unwrap(); | |
| 438 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 439 | + } | |
| 440 | +} |
A
crates/rustwarden-fediverse/src/invite_routes.rs
+542
-0
@@ -0,0 +1,542 @@
| 1 | +//! HTTP routes for cross-instance team invitations (RFC 0014). | |
| 2 | +//! | |
| 3 | +//! Six thin handlers around | |
| 4 | +//! [`crate::services::fediverse_invite_service::FediverseInviteService`]. | |
| 5 | +//! | |
| 6 | +//! Inviter-side (`b.tld`) : | |
| 7 | +//! - `POST /api/v1/teams/{team_id}/members/invite-fediverse` | |
| 8 | +//! - `GET /api/v1/teams/{team_id}/invites` | |
| 9 | +//! - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` | |
| 10 | +//! | |
| 11 | +//! IdP-side (`a.tld`) : | |
| 12 | +//! - `GET /api/v1/fediverse/invites?handle=@user@domain` | |
| 13 | +//! - `POST /api/v1/fediverse/invites/{invite_id}/accept` | |
| 14 | +//! - `POST /api/v1/fediverse/invites/{invite_id}/reject` | |
| 15 | +//! | |
| 16 | +//! Gated by `experimental-fediverse`. | |
| 17 | + | |
| 18 | +use std::sync::Arc; | |
| 19 | +use std::time::Duration; | |
| 20 | + | |
| 21 | +use axum::{ | |
| 22 | + Extension, Json, Router, | |
| 23 | + extract::{Path, Query}, | |
| 24 | + http::StatusCode, | |
| 25 | + routing::{get, post}, | |
| 26 | +}; | |
| 27 | +use sea_orm::DatabaseConnection; | |
| 28 | +use serde::{Deserialize, Serialize}; | |
| 29 | +use uuid::Uuid; | |
| 30 | + | |
| 31 | +use super::error::FediverseError; | |
| 32 | +use rustwarden_core::error::AppError; | |
| 33 | +use crate::services::fediverse_invite_service::FediverseInviteService; | |
| 34 | +use rustwarden_core::services::team_service::TeamRole; | |
| 35 | + | |
| 36 | +/// Per-router state injected into the invite handlers. | |
| 37 | +#[derive(Clone)] | |
| 38 | +pub struct InviteRoutesState { | |
| 39 | + /// TTL applied to every freshly-issued invite. Defaults to 7 days | |
| 40 | + /// when the consumer does not override (set by | |
| 41 | + /// [`crate::fediverse::routes::router_with_auth`]). | |
| 42 | + pub default_ttl: Duration, | |
| 43 | + /// FK of the user the invite is recorded as having been sent | |
| 44 | + /// **by**. In production the consumer overrides this with the | |
| 45 | + /// authenticated admin's `users.id`; the placeholder makes the | |
| 46 | + /// minimal RFC 0014 slice usable end-to-end without forcing | |
| 47 | + /// every consumer to wire JWT extraction immediately. | |
| 48 | + pub system_actor_user_id: Arc<dyn Fn() -> Uuid + Send + Sync>, | |
| 49 | +} | |
| 50 | + | |
| 51 | +impl std::fmt::Debug for InviteRoutesState { | |
| 52 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 53 | + f.debug_struct("InviteRoutesState") | |
| 54 | + .field("default_ttl", &self.default_ttl) | |
| 55 | + .finish_non_exhaustive() | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +// ─── Bodies / responses ────────────────────────────────────────────── | |
| 60 | + | |
| 61 | +/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` body. | |
| 62 | +#[derive(Debug, Deserialize)] | |
| 63 | +pub struct InviteBody { | |
| 64 | + /// `@user@domain` to invite. | |
| 65 | + pub handle: String, | |
| 66 | + /// `"member"` or `"admin"`. | |
| 67 | + pub role: String, | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` 201 response. | |
| 71 | +#[derive(Debug, Serialize)] | |
| 72 | +pub struct InviteResponse { | |
| 73 | + /// Stable invite identifier. | |
| 74 | + pub invite_id: Uuid, | |
| 75 | + /// Canonical AP activity id (used by `b.tld` when sending the | |
| 76 | + /// `Invite{TeamMembership}` activity to the remote inbox). | |
| 77 | + pub activity_id: String, | |
| 78 | + /// RFC3339 expiry timestamp. | |
| 79 | + pub expires_at: String, | |
| 80 | +} | |
| 81 | + | |
| 82 | +/// One row in a `GET /…/invites` listing. | |
| 83 | +#[derive(Debug, Serialize)] | |
| 84 | +pub struct InviteRow { | |
| 85 | + /// Stable invite identifier. | |
| 86 | + pub id: Uuid, | |
| 87 | + /// `@user@domain` invited. | |
| 88 | + pub handle: String, | |
| 89 | + /// `"member"` or `"admin"`. | |
| 90 | + pub role: String, | |
| 91 | + /// Lifecycle state. | |
| 92 | + pub status: String, | |
| 93 | + /// RFC3339 creation timestamp. | |
| 94 | + pub created_at: String, | |
| 95 | + /// RFC3339 expiry timestamp. | |
| 96 | + pub expires_at: String, | |
| 97 | +} | |
| 98 | + | |
| 99 | +/// Query params for the IdP listing endpoint. | |
| 100 | +#[derive(Debug, Deserialize)] | |
| 101 | +pub struct IdpListQuery { | |
| 102 | + /// Handle whose pending invites should be returned. | |
| 103 | + pub handle: String, | |
| 104 | +} | |
| 105 | + | |
| 106 | +// ─── Handlers ──────────────────────────────────────────────────────── | |
| 107 | + | |
| 108 | +async fn invite( | |
| 109 | + Extension(state): Extension<InviteRoutesState>, | |
| 110 | + Extension(db): Extension<DatabaseConnection>, | |
| 111 | + claims: Option<Extension<rustwarden_core::auth::jwt::Claims>>, | |
| 112 | + Path(team_id): Path<Uuid>, | |
| 113 | + Json(body): Json<InviteBody>, | |
| 114 | +) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> { | |
| 115 | + let role = TeamRole::parse(&body.role) | |
| 116 | + .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?; | |
| 117 | + // Prefer the authenticated admin's id (injected by AuthService for | |
| 118 | + // `/api/v1/teams/*` since iter 4 hygiene). Fall back to the | |
| 119 | + // configured placeholder only when no JWT was present — that path | |
| 120 | + // remains for unit tests with `MockDatabase` and seed-style flows | |
| 121 | + // that don't go through the auth middleware. | |
| 122 | + let inviter = match claims { | |
| 123 | + Some(Extension(c)) => c.sub, | |
| 124 | + None => (state.system_actor_user_id)(), | |
| 125 | + }; | |
| 126 | + let model = FediverseInviteService::invite_remote( | |
| 127 | + &db, | |
| 128 | + team_id, | |
| 129 | + &body.handle, | |
| 130 | + role, | |
| 131 | + inviter, | |
| 132 | + state.default_ttl, | |
| 133 | + ) | |
| 134 | + .await?; | |
| 135 | + Ok(( | |
| 136 | + StatusCode::CREATED, | |
| 137 | + Json(InviteResponse { | |
| 138 | + invite_id: model.id, | |
| 139 | + activity_id: model.activity_id, | |
| 140 | + expires_at: model.expires_at.to_rfc3339(), | |
| 141 | + }), | |
| 142 | + )) | |
| 143 | +} | |
| 144 | + | |
| 145 | +async fn list_for_team( | |
| 146 | + Extension(db): Extension<DatabaseConnection>, | |
| 147 | + Path(team_id): Path<Uuid>, | |
| 148 | +) -> Result<Json<Vec<InviteRow>>, FediverseError> { | |
| 149 | + let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?; | |
| 150 | + Ok(Json(rows.into_iter().map(into_row).collect())) | |
| 151 | +} | |
| 152 | + | |
| 153 | +async fn revoke( | |
| 154 | + Extension(db): Extension<DatabaseConnection>, | |
| 155 | + Path((_team_id, invite_id)): Path<(Uuid, Uuid)>, | |
| 156 | +) -> Result<Json<InviteRow>, FediverseError> { | |
| 157 | + let model = FediverseInviteService::revoke(&db, invite_id).await?; | |
| 158 | + Ok(Json(into_row(model))) | |
| 159 | +} | |
| 160 | + | |
| 161 | +async fn idp_list( | |
| 162 | + Extension(db): Extension<DatabaseConnection>, | |
| 163 | + Query(query): Query<IdpListQuery>, | |
| 164 | +) -> Result<Json<Vec<InviteRow>>, FediverseError> { | |
| 165 | + let rows = | |
| 166 | + FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?; | |
| 167 | + Ok(Json(rows.into_iter().map(into_row).collect())) | |
| 168 | +} | |
| 169 | + | |
| 170 | +#[derive(Debug, Deserialize)] | |
| 171 | +struct AcceptBody { | |
| 172 | + /// FK of the resolved `remote_identities.id` that the IdP user maps | |
| 173 | + /// to. In `debug_local_federation` mode the IdP UI / API picks this | |
| 174 | + /// from the same DB; in production a follow-up RFC will let the | |
| 175 | + /// IdP-side mirror the activity and resolve the row automatically. | |
| 176 | + remote_identity_id: Uuid, | |
| 177 | +} | |
| 178 | + | |
| 179 | +async fn idp_accept( | |
| 180 | + Extension(db): Extension<DatabaseConnection>, | |
| 181 | + Path(invite_id): Path<Uuid>, | |
| 182 | + Json(body): Json<AcceptBody>, | |
| 183 | +) -> Result<StatusCode, FediverseError> { | |
| 184 | + FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?; | |
| 185 | + Ok(StatusCode::OK) | |
| 186 | +} | |
| 187 | + | |
| 188 | +async fn idp_reject( | |
| 189 | + Extension(db): Extension<DatabaseConnection>, | |
| 190 | + Path(invite_id): Path<Uuid>, | |
| 191 | +) -> Result<StatusCode, FediverseError> { | |
| 192 | + FediverseInviteService::reject(&db, invite_id).await?; | |
| 193 | + Ok(StatusCode::OK) | |
| 194 | +} | |
| 195 | + | |
| 196 | +fn into_row(m: crate::models::fediverse_pending_invite::Model) -> InviteRow { | |
| 197 | + InviteRow { | |
| 198 | + id: m.id, | |
| 199 | + handle: m.invited_handle, | |
| 200 | + role: m.role, | |
| 201 | + status: m.status, | |
| 202 | + created_at: m.created_at.to_rfc3339(), | |
| 203 | + expires_at: m.expires_at.to_rfc3339(), | |
| 204 | + } | |
| 205 | +} | |
| 206 | + | |
| 207 | +/// Build the invite-routes router. | |
| 208 | +pub fn router(state: InviteRoutesState) -> Router<DatabaseConnection> { | |
| 209 | + Router::new() | |
| 210 | + .route( | |
| 211 | + "/api/v1/teams/{team_id}/members/invite-fediverse", | |
| 212 | + post(invite), | |
| 213 | + ) | |
| 214 | + .route("/api/v1/teams/{team_id}/invites", get(list_for_team)) | |
| 215 | + .route( | |
| 216 | + "/api/v1/teams/{team_id}/invites/{invite_id}/revoke", | |
| 217 | + post(revoke), | |
| 218 | + ) | |
| 219 | + .route("/api/v1/fediverse/invites", get(idp_list)) | |
| 220 | + .route( | |
| 221 | + "/api/v1/fediverse/invites/{invite_id}/accept", | |
| 222 | + post(idp_accept), | |
| 223 | + ) | |
| 224 | + .route( | |
| 225 | + "/api/v1/fediverse/invites/{invite_id}/reject", | |
| 226 | + post(idp_reject), | |
| 227 | + ) | |
| 228 | + .layer(Extension(state)) | |
| 229 | +} | |
| 230 | + | |
| 231 | +// `From<AppError>` for `FediverseError` is provided via the `App` | |
| 232 | +// variant; nothing extra to wire here. | |
| 233 | +#[allow(dead_code)] | |
| 234 | +fn _ensure_app_error_conversion(err: AppError) -> FediverseError { | |
| 235 | + err.into() | |
| 236 | +} | |
| 237 | + | |
| 238 | +#[cfg(test)] | |
| 239 | +mod tests { | |
| 240 | + use super::*; | |
| 241 | + use crate::models::fediverse_pending_invite; | |
| 242 | + use axum::body::Body; | |
| 243 | + use axum::http::Request; | |
| 244 | + use chrono::{Duration as ChronoDuration, Utc}; | |
| 245 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 246 | + use tower::ServiceExt; | |
| 247 | + | |
| 248 | + fn fresh_invite(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model { | |
| 249 | + let now = Utc::now(); | |
| 250 | + fediverse_pending_invite::Model { | |
| 251 | + id, | |
| 252 | + activity_id: format!("https://b.tld/invites/{id}"), | |
| 253 | + team_id, | |
| 254 | + invited_handle: "alice@a.tld".into(), | |
| 255 | + invited_remote_identity_id: None, | |
| 256 | + role: "member".into(), | |
| 257 | + invited_by_user_id: Uuid::new_v4(), | |
| 258 | + status: "pending".into(), | |
| 259 | + created_at: now, | |
| 260 | + expires_at: now + ChronoDuration::days(7), | |
| 261 | + decided_at: None, | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + fn build_router(db: DatabaseConnection) -> Router { | |
| 266 | + let state = InviteRoutesState { | |
| 267 | + default_ttl: Duration::from_secs(7 * 86400), | |
| 268 | + system_actor_user_id: Arc::new(Uuid::new_v4), | |
| 269 | + }; | |
| 270 | + router(state) | |
| 271 | + .layer(Extension(db)) | |
| 272 | + .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection()) | |
| 273 | + } | |
| 274 | + | |
| 275 | + fn req_post(uri: &str, body: &str) -> Request<Body> { | |
| 276 | + Request::builder() | |
| 277 | + .uri(uri) | |
| 278 | + .method("POST") | |
| 279 | + .header("content-type", "application/json") | |
| 280 | + .body(Body::from(body.to_string())) | |
| 281 | + .unwrap() | |
| 282 | + } | |
| 283 | + | |
| 284 | + fn req_get(uri: &str) -> Request<Body> { | |
| 285 | + Request::builder() | |
| 286 | + .uri(uri) | |
| 287 | + .method("GET") | |
| 288 | + .body(Body::empty()) | |
| 289 | + .unwrap() | |
| 290 | + } | |
| 291 | + | |
| 292 | + // ─── Inviter side ─────────────────────────────────────────────── | |
| 293 | + | |
| 294 | + #[tokio::test] | |
| 295 | + async fn invite_returns_201_with_invite_id() { | |
| 296 | + let team_id = Uuid::new_v4(); | |
| 297 | + let row = fresh_invite(Uuid::new_v4(), team_id); | |
| 298 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 299 | + .append_query_results([vec![row]]) | |
| 300 | + .into_connection(); | |
| 301 | + let body = serde_json::json!({"handle":"alice@a.tld","role":"member"}).to_string(); | |
| 302 | + let resp = build_router(db) | |
| 303 | + .oneshot(req_post( | |
| 304 | + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 305 | + &body, | |
| 306 | + )) | |
| 307 | + .await | |
| 308 | + .unwrap(); | |
| 309 | + assert_eq!(resp.status(), StatusCode::CREATED); | |
| 310 | + } | |
| 311 | + | |
| 312 | + #[tokio::test] | |
| 313 | + async fn invite_returns_400_for_invalid_role() { | |
| 314 | + let team_id = Uuid::new_v4(); | |
| 315 | + let db: DatabaseConnection = | |
| 316 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 317 | + let body = serde_json::json!({"handle":"alice@a.tld","role":"superuser"}).to_string(); | |
| 318 | + let resp = build_router(db) | |
| 319 | + .oneshot(req_post( | |
| 320 | + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 321 | + &body, | |
| 322 | + )) | |
| 323 | + .await | |
| 324 | + .unwrap(); | |
| 325 | + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); | |
| 326 | + } | |
| 327 | + | |
| 328 | + #[tokio::test] | |
| 329 | + async fn list_pending_returns_200_with_array() { | |
| 330 | + let team_id = Uuid::new_v4(); | |
| 331 | + let r1 = fresh_invite(Uuid::new_v4(), team_id); | |
| 332 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 333 | + .append_query_results([vec![r1]]) | |
| 334 | + .into_connection(); | |
| 335 | + let resp = build_router(db) | |
| 336 | + .oneshot(req_get(&format!("/api/v1/teams/{team_id}/invites"))) | |
| 337 | + .await | |
| 338 | + .unwrap(); | |
| 339 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 340 | + } | |
| 341 | + | |
| 342 | + #[tokio::test] | |
| 343 | + async fn revoke_returns_200_for_pending_invite() { | |
| 344 | + let team_id = Uuid::new_v4(); | |
| 345 | + let invite_id = Uuid::new_v4(); | |
| 346 | + let row = fresh_invite(invite_id, team_id); | |
| 347 | + let mut after = row.clone(); | |
| 348 | + after.status = "revoked".into(); | |
| 349 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 350 | + .append_query_results([vec![row]]) | |
| 351 | + .append_query_results([vec![after]]) | |
| 352 | + .append_exec_results([MockExecResult { | |
| 353 | + last_insert_id: 0, | |
| 354 | + rows_affected: 1, | |
| 355 | + }]) | |
| 356 | + .into_connection(); | |
| 357 | + let resp = build_router(db) | |
| 358 | + .oneshot(req_post( | |
| 359 | + &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), | |
| 360 | + "", | |
| 361 | + )) | |
| 362 | + .await | |
| 363 | + .unwrap(); | |
| 364 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 365 | + } | |
| 366 | + | |
| 367 | + #[tokio::test] | |
| 368 | + async fn revoke_returns_404_for_unknown_invite() { | |
| 369 | + let team_id = Uuid::new_v4(); | |
| 370 | + let invite_id = Uuid::new_v4(); | |
| 371 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 372 | + .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) | |
| 373 | + .into_connection(); | |
| 374 | + let resp = build_router(db) | |
| 375 | + .oneshot(req_post( | |
| 376 | + &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"), | |
| 377 | + "", | |
| 378 | + )) | |
| 379 | + .await | |
| 380 | + .unwrap(); | |
| 381 | + assert_eq!(resp.status(), StatusCode::NOT_FOUND); | |
| 382 | + } | |
| 383 | + | |
| 384 | + // ─── IdP side ────────────────────────────────────────────────── | |
| 385 | + | |
| 386 | + #[tokio::test] | |
| 387 | + async fn idp_list_returns_200_with_invites_for_handle() { | |
| 388 | + let team_id = Uuid::new_v4(); | |
| 389 | + let r1 = fresh_invite(Uuid::new_v4(), team_id); | |
| 390 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 391 | + .append_query_results([vec![r1]]) | |
| 392 | + .into_connection(); | |
| 393 | + let resp = build_router(db) | |
| 394 | + .oneshot(req_get("/api/v1/fediverse/invites?handle=alice@a.tld")) | |
| 395 | + .await | |
| 396 | + .unwrap(); | |
| 397 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 398 | + } | |
| 399 | + | |
| 400 | + #[tokio::test] | |
| 401 | + async fn idp_list_returns_empty_array_when_no_invites() { | |
| 402 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 403 | + .append_query_results([Vec::<fediverse_pending_invite::Model>::new()]) | |
| 404 | + .into_connection(); | |
| 405 | + let resp = build_router(db) | |
| 406 | + .oneshot(req_get("/api/v1/fediverse/invites?handle=ghost@nowhere.tld")) | |
| 407 | + .await | |
| 408 | + .unwrap(); | |
| 409 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 410 | + } | |
| 411 | + | |
| 412 | + #[tokio::test] | |
| 413 | + async fn idp_accept_returns_200_for_pending() { | |
| 414 | + let team_id = Uuid::new_v4(); | |
| 415 | + let invite_id = Uuid::new_v4(); | |
| 416 | + let remote_id = Uuid::new_v4(); | |
| 417 | + let row = fresh_invite(invite_id, team_id); | |
| 418 | + let mut after_invite = row.clone(); | |
| 419 | + after_invite.status = "accepted".into(); | |
| 420 | + after_invite.invited_remote_identity_id = Some(remote_id); | |
| 421 | + let now = Utc::now(); | |
| 422 | + let new_member = rustwarden_core::models::team_member::Model { | |
| 423 | + id: Uuid::new_v4(), | |
| 424 | + team_id, | |
| 425 | + user_id: None, | |
| 426 | + role: "member".into(), | |
| 427 | + joined_at: now, | |
| 428 | + remote_identity_id: Some(remote_id), | |
| 429 | + }; | |
| 430 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 431 | + .append_query_results([vec![row]]) | |
| 432 | + .append_query_results([vec![after_invite]]) | |
| 433 | + .append_query_results([vec![new_member]]) | |
| 434 | + .append_exec_results([ | |
| 435 | + MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 436 | + MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 437 | + ]) | |
| 438 | + .into_connection(); | |
| 439 | + let body = serde_json::json!({"remote_identity_id":remote_id}).to_string(); | |
| 440 | + let resp = build_router(db) | |
| 441 | + .oneshot(req_post( | |
| 442 | + &format!("/api/v1/fediverse/invites/{invite_id}/accept"), | |
| 443 | + &body, | |
| 444 | + )) | |
| 445 | + .await | |
| 446 | + .unwrap(); | |
| 447 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 448 | + } | |
| 449 | + | |
| 450 | + #[tokio::test] | |
| 451 | + async fn idp_accept_returns_400_for_already_accepted() { | |
| 452 | + let team_id = Uuid::new_v4(); | |
| 453 | + let invite_id = Uuid::new_v4(); | |
| 454 | + let mut row = fresh_invite(invite_id, team_id); | |
| 455 | + row.status = "accepted".into(); | |
| 456 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 457 | + .append_query_results([vec![row]]) | |
| 458 | + .into_connection(); | |
| 459 | + let body = serde_json::json!({"remote_identity_id":Uuid::new_v4()}).to_string(); | |
| 460 | + let resp = build_router(db) | |
| 461 | + .oneshot(req_post( | |
| 462 | + &format!("/api/v1/fediverse/invites/{invite_id}/accept"), | |
| 463 | + &body, | |
| 464 | + )) | |
| 465 | + .await | |
| 466 | + .unwrap(); | |
| 467 | + assert!( | |
| 468 | + resp.status() == StatusCode::BAD_REQUEST | |
| 469 | + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 470 | + "expected 400/422, got {}", | |
| 471 | + resp.status() | |
| 472 | + ); | |
| 473 | + } | |
| 474 | + | |
| 475 | + #[tokio::test] | |
| 476 | + async fn idp_reject_returns_200_for_pending() { | |
| 477 | + let team_id = Uuid::new_v4(); | |
| 478 | + let invite_id = Uuid::new_v4(); | |
| 479 | + let row = fresh_invite(invite_id, team_id); | |
| 480 | + let mut after = row.clone(); | |
| 481 | + after.status = "rejected".into(); | |
| 482 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 483 | + .append_query_results([vec![row]]) | |
| 484 | + .append_query_results([vec![after]]) | |
| 485 | + .append_exec_results([MockExecResult { | |
| 486 | + last_insert_id: 0, | |
| 487 | + rows_affected: 1, | |
| 488 | + }]) | |
| 489 | + .into_connection(); | |
| 490 | + let resp = build_router(db) | |
| 491 | + .oneshot(req_post( | |
| 492 | + &format!("/api/v1/fediverse/invites/{invite_id}/reject"), | |
| 493 | + "", | |
| 494 | + )) | |
| 495 | + .await | |
| 496 | + .unwrap(); | |
| 497 | + assert_eq!(resp.status(), StatusCode::OK); | |
| 498 | + } | |
| 499 | + | |
| 500 | + #[tokio::test] | |
| 501 | + async fn idp_reject_returns_400_for_already_decided() { | |
| 502 | + let team_id = Uuid::new_v4(); | |
| 503 | + let invite_id = Uuid::new_v4(); | |
| 504 | + let mut row = fresh_invite(invite_id, team_id); | |
| 505 | + row.status = "rejected".into(); | |
| 506 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 507 | + .append_query_results([vec![row]]) | |
| 508 | + .into_connection(); | |
| 509 | + let resp = build_router(db) | |
| 510 | + .oneshot(req_post( | |
| 511 | + &format!("/api/v1/fediverse/invites/{invite_id}/reject"), | |
| 512 | + "", | |
| 513 | + )) | |
| 514 | + .await | |
| 515 | + .unwrap(); | |
| 516 | + assert!( | |
| 517 | + resp.status() == StatusCode::BAD_REQUEST | |
| 518 | + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY, | |
| 519 | + ); | |
| 520 | + } | |
| 521 | + | |
| 522 | + #[tokio::test] | |
| 523 | + async fn invite_uses_default_ttl_from_state() { | |
| 524 | + // The `default_ttl` flows through the service; the mock | |
| 525 | + // doesn't reflect it back to the response, so we only | |
| 526 | + // assert status 201 and that the row was inserted. | |
| 527 | + let team_id = Uuid::new_v4(); | |
| 528 | + let row = fresh_invite(Uuid::new_v4(), team_id); | |
| 529 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 530 | + .append_query_results([vec![row]]) | |
| 531 | + .into_connection(); | |
| 532 | + let body = serde_json::json!({"handle":"alice@a.tld","role":"admin"}).to_string(); | |
| 533 | + let resp = build_router(db) | |
| 534 | + .oneshot(req_post( | |
| 535 | + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"), | |
| 536 | + &body, | |
| 537 | + )) | |
| 538 | + .await | |
| 539 | + .unwrap(); | |
| 540 | + assert_eq!(resp.status(), StatusCode::CREATED); | |
| 541 | + } | |
| 542 | +} |
A
crates/rustwarden-fediverse/src/lib.rs
+50
-0
@@ -0,0 +1,50 @@
| 1 | +//! ActivityPub identity federation for Rustwarden (RFCs 0001–0025). | |
| 2 | +//! | |
| 3 | +//! Provides the discovery layer (WebFinger, Person actor, NodeInfo), | |
| 4 | +//! AP-native authentication flow, cross-instance team invitations, | |
| 5 | +//! lifecycle signals, the outbound delivery worker, and HTTP signatures. | |
| 6 | +//! | |
| 7 | +//! Extracted from `rustwarden-core::fediverse` per | |
| 8 | +//! `docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md`. | |
| 9 | +//! Consumers add this crate as an optional Cargo dependency rather than | |
| 10 | +//! flipping a `rustwarden-core` feature flag. | |
| 11 | + | |
| 12 | +/// Person actor handler with `Accept` content negotiation. | |
| 13 | +pub mod actor; | |
| 14 | +/// HTTP routes for the AP-native authentication flow (RFC 0013). | |
| 15 | +pub mod auth_routes; | |
| 16 | +/// Federation configuration loaded from environment variables. | |
| 17 | +pub mod config; | |
| 18 | +/// Federation-specific error type, convertible to | |
| 19 | +/// [`AppError`](rustwarden_core::error::AppError). | |
| 20 | +pub mod error; | |
| 21 | +/// HTTP client abstraction used by the remote-identity resolver. | |
| 22 | +pub mod fetcher; | |
| 23 | +/// Mastodon-compatible HTTP signatures (draft-cavage RSA-SHA256, RFC 0015). | |
| 24 | +pub mod http_signatures; | |
| 25 | +/// `POST /inbox` handler dispatching AP lifecycle activities (RFC 0012). | |
| 26 | +pub mod inbox; | |
| 27 | +/// HTTP routes for cross-instance team invitations (RFC 0014). | |
| 28 | +pub mod invite_routes; | |
| 29 | +/// SeaORM entity models for federation tables. | |
| 30 | +pub mod models; | |
| 31 | +/// NodeInfo discovery + 2.1 handlers. | |
| 32 | +pub mod nodeinfo; | |
| 33 | +/// `LocalUserResolver` trait + SeaORM-backed default implementation. | |
| 34 | +pub mod resolver; | |
| 35 | +/// Router builder: assembles the federation Axum router. | |
| 36 | +pub mod routes; | |
| 37 | +/// Federation business-logic services. | |
| 38 | +pub mod services; | |
| 39 | +/// Shared state injected into federation handlers. | |
| 40 | +pub mod state; | |
| 41 | +/// JSON types serialised on federation endpoints. | |
| 42 | +pub mod types; | |
| 43 | +/// WebFinger handler (RFC 7033). | |
| 44 | +pub mod webfinger; | |
| 45 | + | |
| 46 | +pub use config::{FediverseConfig, FediverseConfigError}; | |
| 47 | +pub use error::FediverseError; | |
| 48 | +pub use fetcher::{RemoteFetcher, UnconfiguredFetcher}; | |
| 49 | +pub use resolver::{LocalUserResolver, UserSummary}; | |
| 50 | +pub use state::FediverseState; |
A
crates/rustwarden-fediverse/src/models/fediverse_auth_challenge.rs
+46
-0
@@ -0,0 +1,46 @@
| 1 | +//! SeaORM entity for `fediverse_auth_challenges` (RFC 0006). | |
| 2 | +//! | |
| 3 | +//! State machine row backing the AP-native authentication flow on the | |
| 4 | +//! client (`b.tld`) side. | |
| 5 | + | |
| 6 | +use chrono::{DateTime, Utc}; | |
| 7 | +use sea_orm::entity::prelude::*; | |
| 8 | +use serde::{Deserialize, Serialize}; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +/// Persistent record of one in-flight authentication challenge. | |
| 12 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | +#[sea_orm(table_name = "fediverse_auth_challenges")] | |
| 14 | +pub struct Model { | |
| 15 | + /// Stable PK. | |
| 16 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | + pub id: Uuid, | |
| 18 | + /// 256-bit URL-safe base64 nonce embedded in the AP `AuthRequest` | |
| 19 | + /// activity. Must match the nonce echoed by the IdP `Accept`. | |
| 20 | + #[sea_orm(unique)] | |
| 21 | + pub nonce: String, | |
| 22 | + /// `@user@domain` handle the user typed (verbatim). | |
| 23 | + pub handle: String, | |
| 24 | + /// Domain part of the handle (lowercased). | |
| 25 | + pub domain: String, | |
| 26 | + /// Optional FK to `remote_identities.id` once the IdP has approved | |
| 27 | + /// the challenge. | |
| 28 | + pub requested_remote_id: Option<Uuid>, | |
| 29 | + /// `pending`, `approved`, `rejected`, `expired`, or `consumed`. | |
| 30 | + pub status: String, | |
| 31 | + /// Where to redirect the browser after a successful approve. | |
| 32 | + pub redirect_url: Option<String>, | |
| 33 | + /// Insertion timestamp. | |
| 34 | + pub created_at: DateTime<Utc>, | |
| 35 | + /// Hard deadline (`created_at + ttl`) after which the row is | |
| 36 | + /// considered `Expired` regardless of status. | |
| 37 | + pub expires_at: DateTime<Utc>, | |
| 38 | + /// Set the moment a JWT is minted from this challenge (one-shot). | |
| 39 | + pub consumed_at: Option<DateTime<Utc>>, | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// SeaORM relations (none modelled here). | |
| 43 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 44 | +pub enum Relation {} | |
| 45 | + | |
| 46 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/models/fediverse_blocked_domain.rs
+35
-0
@@ -0,0 +1,35 @@
| 1 | +//! `fediverse_blocked_domains` SeaORM entity (RFC 0004). | |
| 2 | +//! | |
| 3 | +//! Blocklist of remote Fediverse domains explicitly refused by the local | |
| 4 | +//! admin. Used when `app_settings.fediverse_trust_mode = "open_with_blocklist"`. | |
| 5 | + | |
| 6 | +use chrono::{DateTime, Utc}; | |
| 7 | +use sea_orm::entity::prelude::*; | |
| 8 | +use serde::{Deserialize, Serialize}; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +/// `fediverse_blocked_domains` row. | |
| 12 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | +#[sea_orm(table_name = "fediverse_blocked_domains")] | |
| 14 | +pub struct Model { | |
| 15 | + /// Primary key. | |
| 16 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | + pub id: Uuid, | |
| 18 | + /// Bare domain name, lowercased and stripped of `www.` prefix. | |
| 19 | + #[sea_orm(unique)] | |
| 20 | + pub domain: String, | |
| 21 | + /// `"suspend"`, `"silence"`, or `"reject_auth"`. | |
| 22 | + pub severity: String, | |
| 23 | + /// Optional admin reason for blocking. | |
| 24 | + pub reason: Option<String>, | |
| 25 | + /// User that added the entry. | |
| 26 | + pub added_by_user_id: Option<Uuid>, | |
| 27 | + /// Insertion timestamp. | |
| 28 | + pub created_at: DateTime<Utc>, | |
| 29 | +} | |
| 30 | + | |
| 31 | +/// SeaORM relations (none). | |
| 32 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 33 | +pub enum Relation {} | |
| 34 | + | |
| 35 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/models/fediverse_outbound_queue.rs
+51
-0
@@ -0,0 +1,51 @@
| 1 | +//! SeaORM entity for the `fediverse_outbound_queue` table (RFC 0011). | |
| 2 | +//! | |
| 3 | +//! Persistent journal of ActivityPub activities that must be delivered | |
| 4 | +//! to remote inboxes by [`rustwarden_core::services::fediverse_outbound_service::OutboundWorker`]. | |
| 5 | + | |
| 6 | +use chrono::{DateTime, Utc}; | |
| 7 | +use sea_orm::entity::prelude::*; | |
| 8 | +use serde::{Deserialize, Serialize}; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +/// Persistent record of one outbound delivery (one-to-one with the | |
| 12 | +/// `fediverse_outbound_queue` row). | |
| 13 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 14 | +#[sea_orm(table_name = "fediverse_outbound_queue")] | |
| 15 | +pub struct Model { | |
| 16 | + /// Stable PK. | |
| 17 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 18 | + pub id: Uuid, | |
| 19 | + /// Canonical AP `id` of the activity being delivered. | |
| 20 | + pub activity_id: String, | |
| 21 | + /// Pre-rendered JSON-LD body posted to `target_inbox`. | |
| 22 | + pub payload_json: String, | |
| 23 | + /// Remote inbox URL. | |
| 24 | + pub target_inbox: String, | |
| 25 | + /// Domain the inbox belongs to (used for trust checks). | |
| 26 | + pub target_domain: String, | |
| 27 | + /// `pending`, `delivered`, `failed`, or `dead`. | |
| 28 | + pub status: String, | |
| 29 | + /// Number of delivery attempts already performed. | |
| 30 | + pub attempts: i32, | |
| 31 | + /// Maximum attempts before the row is moved to `dead`. | |
| 32 | + pub max_attempts: i32, | |
| 33 | + /// Last delivery error (truncated). | |
| 34 | + pub last_error: Option<String>, | |
| 35 | + /// Earliest moment the row is eligible for re-delivery. | |
| 36 | + pub next_retry_at: Option<DateTime<Utc>>, | |
| 37 | + /// Insertion timestamp. | |
| 38 | + pub created_at: DateTime<Utc>, | |
| 39 | + /// Successful delivery timestamp. | |
| 40 | + pub delivered_at: Option<DateTime<Utc>>, | |
| 41 | + /// Local user the activity is sent on behalf of (used for HTTP-Sig | |
| 42 | + /// signing in RFC 0010; nullable while only `NoopDelivery` is | |
| 43 | + /// shipped). | |
| 44 | + pub actor_user_id: Option<Uuid>, | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// SeaORM relations (none for this table). | |
| 48 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 49 | +pub enum Relation {} | |
| 50 | + | |
| 51 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/models/fediverse_pending_invite.rs
+49
-0
@@ -0,0 +1,49 @@
| 1 | +//! SeaORM entity for `fediverse_pending_invites` (RFC 0009). | |
| 2 | +//! | |
| 3 | +//! Persistent record of cross-instance team-membership invitations | |
| 4 | +//! issued by a Rustwarden host (`b.tld`) towards a remote user | |
| 5 | +//! (`alice@a.tld`). The activity reference (`activity_id`) lets the | |
| 6 | +//! AP `Accept{Invite}` / `Reject{Invite}` callbacks find the right | |
| 7 | +//! row without ambiguity. | |
| 8 | + | |
| 9 | +use chrono::{DateTime, Utc}; | |
| 10 | +use sea_orm::entity::prelude::*; | |
| 11 | +use serde::{Deserialize, Serialize}; | |
| 12 | +use uuid::Uuid; | |
| 13 | + | |
| 14 | +/// Persistent record of one in-flight invitation. | |
| 15 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 16 | +#[sea_orm(table_name = "fediverse_pending_invites")] | |
| 17 | +pub struct Model { | |
| 18 | + /// Stable PK. | |
| 19 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 20 | + pub id: Uuid, | |
| 21 | + /// AP `Invite` activity id (e.g. `https://b.tld/invites/<uuid>`). | |
| 22 | + #[sea_orm(unique)] | |
| 23 | + pub activity_id: String, | |
| 24 | + /// FK to `teams.id`. | |
| 25 | + pub team_id: Uuid, | |
| 26 | + /// `@user@domain` handle invited. | |
| 27 | + pub invited_handle: String, | |
| 28 | + /// FK to `remote_identities.id`, populated when the AP `Accept` | |
| 29 | + /// arrives and the remote actor is fully resolved. | |
| 30 | + pub invited_remote_identity_id: Option<Uuid>, | |
| 31 | + /// Team role to assign on accept (`"member"`, `"admin"`). | |
| 32 | + pub role: String, | |
| 33 | + /// FK to `users.id` of the inviter. | |
| 34 | + pub invited_by_user_id: Uuid, | |
| 35 | + /// `pending`, `accepted`, `rejected`, `revoked`, or `expired`. | |
| 36 | + pub status: String, | |
| 37 | + /// Insertion timestamp. | |
| 38 | + pub created_at: DateTime<Utc>, | |
| 39 | + /// Hard deadline. | |
| 40 | + pub expires_at: DateTime<Utc>, | |
| 41 | + /// Set when the invite reaches a terminal state. | |
| 42 | + pub decided_at: Option<DateTime<Utc>>, | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// SeaORM relations (none modelled here). | |
| 46 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 47 | +pub enum Relation {} | |
| 48 | + | |
| 49 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/models/fediverse_trusted_domain.rs
+33
-0
@@ -0,0 +1,33 @@
| 1 | +//! `fediverse_trusted_domains` SeaORM entity (RFC 0004). | |
| 2 | +//! | |
| 3 | +//! Allowlist of remote Fediverse domains explicitly trusted by the local | |
| 4 | +//! admin. Used when `app_settings.fediverse_trust_mode = "allowlist"`. | |
| 5 | + | |
| 6 | +use chrono::{DateTime, Utc}; | |
| 7 | +use sea_orm::entity::prelude::*; | |
| 8 | +use serde::{Deserialize, Serialize}; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +/// `fediverse_trusted_domains` row. | |
| 12 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 13 | +#[sea_orm(table_name = "fediverse_trusted_domains")] | |
| 14 | +pub struct Model { | |
| 15 | + /// Primary key. | |
| 16 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 17 | + pub id: Uuid, | |
| 18 | + /// Bare domain name, lowercased and stripped of `www.` prefix. | |
| 19 | + #[sea_orm(unique)] | |
| 20 | + pub domain: String, | |
| 21 | + /// Optional admin note (motivation for the trust). | |
| 22 | + pub note: Option<String>, | |
| 23 | + /// User that added the entry (NULL for bootstrap / system insertion). | |
| 24 | + pub added_by_user_id: Option<Uuid>, | |
| 25 | + /// Insertion timestamp. | |
| 26 | + pub created_at: DateTime<Utc>, | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// SeaORM relations (none). | |
| 30 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 31 | +pub enum Relation {} | |
| 32 | + | |
| 33 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/models/mod.rs
+18
-0
@@ -0,0 +1,18 @@
| 1 | +//! SeaORM entity models for the federation tables. | |
| 2 | +//! | |
| 3 | +//! Schema is owned by `rustwarden-core` (migrations live in | |
| 4 | +//! `rustwarden-core/migrations/`); this module only contains the Rust-side | |
| 5 | +//! entity definitions consumed by federation services. | |
| 6 | + | |
| 7 | +/// AP-native authentication challenge row (RFC 0006). | |
| 8 | +pub mod fediverse_auth_challenge; | |
| 9 | +/// Federation blocklist (RFC 0004). | |
| 10 | +pub mod fediverse_blocked_domain; | |
| 11 | +/// Persistent outbound delivery queue (RFC 0011). | |
| 12 | +pub mod fediverse_outbound_queue; | |
| 13 | +/// Cross-instance team-membership invitation (RFC 0009). | |
| 14 | +pub mod fediverse_pending_invite; | |
| 15 | +/// Federation allowlist (RFC 0004). | |
| 16 | +pub mod fediverse_trusted_domain; | |
| 17 | +/// Remote ActivityPub identity cache (RFC 0003). | |
| 18 | +pub mod remote_identity; |
A
crates/rustwarden-fediverse/src/models/remote_identity.rs
+61
-0
@@ -0,0 +1,61 @@
| 1 | +//! Remote ActivityPub identity SeaORM entity (RFC 0003). | |
| 2 | +//! | |
| 3 | +//! Local cache of remote actors resolved via WebFinger + actor fetch. | |
| 4 | +//! Created and maintained by | |
| 5 | +//! [`RemoteIdentityService`](rustwarden_core::services::remote_identity_service::RemoteIdentityService). | |
| 6 | +//! | |
| 7 | +//! Gated by the `experimental-fediverse` Cargo feature. | |
| 8 | + | |
| 9 | +use chrono::{DateTime, Utc}; | |
| 10 | +use sea_orm::entity::prelude::*; | |
| 11 | +use serde::{Deserialize, Serialize}; | |
| 12 | +use uuid::Uuid; | |
| 13 | + | |
| 14 | +/// `remote_identities` row. | |
| 15 | +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] | |
| 16 | +#[sea_orm(table_name = "remote_identities")] | |
| 17 | +pub struct Model { | |
| 18 | + /// Stable internal identifier. | |
| 19 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 20 | + pub id: Uuid, | |
| 21 | + /// Canonical ActivityPub identifier of the actor (e.g. | |
| 22 | + /// `https://other.tld/users/alice`). Unique. | |
| 23 | + #[sea_orm(unique)] | |
| 24 | + pub ap_id: String, | |
| 25 | + /// Local part of the handle (`alice` in `@alice@other.tld`). | |
| 26 | + pub username: String, | |
| 27 | + /// Remote domain (`other.tld`). | |
| 28 | + pub domain: String, | |
| 29 | + /// Optional display name advertised by the actor. | |
| 30 | + pub display_name: Option<String>, | |
| 31 | + /// Optional bio in HTML. | |
| 32 | + pub summary_html: Option<String>, | |
| 33 | + /// Inbox URL where activities targeting this actor must be POSTed. | |
| 34 | + pub inbox_url: String, | |
| 35 | + /// Optional shared inbox (instance-wide fan-out optimisation). | |
| 36 | + pub shared_inbox_url: Option<String>, | |
| 37 | + /// PEM-encoded RSA public key used for HTTP signature verification. | |
| 38 | + pub public_key_pem: String, | |
| 39 | + /// Optional avatar URL. | |
| 40 | + pub icon_url: Option<String>, | |
| 41 | + /// `Person`, `Group`, `Service`, `Application`, `Organization`. | |
| 42 | + pub actor_type: String, | |
| 43 | + /// Raw JSON document fetched from the remote actor URL (kept for | |
| 44 | + /// future re-processing if the protocol evolves). | |
| 45 | + pub raw_json: Option<serde_json::Value>, | |
| 46 | + /// Timestamp of the last successful fetch — used by the cache TTL check. | |
| 47 | + pub last_fetched_at: DateTime<Utc>, | |
| 48 | + /// Set when the remote IdP signals account deletion (`Delete{Actor}`). | |
| 49 | + pub disabled_at: Option<DateTime<Utc>>, | |
| 50 | + /// Row creation timestamp. | |
| 51 | + pub created_at: DateTime<Utc>, | |
| 52 | + /// Last row mutation timestamp. | |
| 53 | + pub updated_at: DateTime<Utc>, | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// SeaORM relations for `remote_identities` (none yet — joined references | |
| 57 | +/// arrive with RFC 0008). | |
| 58 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 59 | +pub enum Relation {} | |
| 60 | + | |
| 61 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-fediverse/src/nodeinfo.rs
+194
-0
@@ -0,0 +1,194 @@
| 1 | +//! NodeInfo discovery + 2.1 detail handlers. | |
| 2 | +//! | |
| 3 | +//! `GET /.well-known/nodeinfo` returns a discovery document linking to the | |
| 4 | +//! concrete schema versions we expose. `GET /nodeinfo/2.1` returns the | |
| 5 | +//! full metadata document used by Fediverse directories. | |
| 6 | + | |
| 7 | +use axum::{Extension, Json}; | |
| 8 | + | |
| 9 | +use super::error::FediverseError; | |
| 10 | +use super::state::FediverseState; | |
| 11 | +use super::types::{ | |
| 12 | + NodeInfo, NodeInfoDiscovery, NodeInfoLink, NodeInfoServices, NodeInfoSoftware, NodeInfoUsage, | |
| 13 | + NodeInfoUsageUsers, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +const NODEINFO_2_1_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.1"; | |
| 17 | + | |
| 18 | +/// `GET /.well-known/nodeinfo`. | |
| 19 | +pub async fn discovery( | |
| 20 | + Extension(state): Extension<FediverseState>, | |
| 21 | +) -> Result<Json<NodeInfoDiscovery>, FediverseError> { | |
| 22 | + let href = format!("{}nodeinfo/2.1", state.config.base_url()); | |
| 23 | + Ok(Json(NodeInfoDiscovery { | |
| 24 | + links: vec![NodeInfoLink { | |
| 25 | + rel: NODEINFO_2_1_REL.to_string(), | |
| 26 | + href, | |
| 27 | + }], | |
| 28 | + })) | |
| 29 | +} | |
| 30 | + | |
| 31 | +/// `GET /nodeinfo/2.1`. | |
| 32 | +pub async fn detail( | |
| 33 | + Extension(state): Extension<FediverseState>, | |
| 34 | +) -> Result<Json<NodeInfo>, FediverseError> { | |
| 35 | + let total = state.resolver.count_users_total().await?; | |
| 36 | + let active_month = state.resolver.count_users_active_since(30).await?; | |
| 37 | + let active_halfyear = state.resolver.count_users_active_since(180).await?; | |
| 38 | + let local_posts = state.resolver.count_local_posts().await?; | |
| 39 | + | |
| 40 | + Ok(Json(NodeInfo { | |
| 41 | + version: "2.1".to_string(), | |
| 42 | + software: NodeInfoSoftware { | |
| 43 | + name: "rustwarden".to_string(), | |
| 44 | + version: env!("CARGO_PKG_VERSION").to_string(), | |
| 45 | + repository: Some("https://github.com/EBO/rustwarden".to_string()), | |
| 46 | + homepage: None, | |
| 47 | + }, | |
| 48 | + protocols: vec!["activitypub".to_string()], | |
| 49 | + services: NodeInfoServices::default(), | |
| 50 | + usage: NodeInfoUsage { | |
| 51 | + users: NodeInfoUsageUsers { | |
| 52 | + total, | |
| 53 | + active_month, | |
| 54 | + active_halfyear, | |
| 55 | + }, | |
| 56 | + local_posts, | |
| 57 | + }, | |
| 58 | + // RFC 0004 will source this from `app_settings.allow_signup`. For now | |
| 59 | + // a conservative default: closed registration. | |
| 60 | + open_registrations: false, | |
| 61 | + metadata: serde_json::json!({}), | |
| 62 | + })) | |
| 63 | +} | |
| 64 | + | |
| 65 | +#[cfg(test)] | |
| 66 | +mod tests { | |
| 67 | + use super::*; | |
| 68 | + use crate::config::FediverseConfig; | |
| 69 | + use crate::resolver::{LocalUserResolver, UserSummary}; | |
| 70 | + use async_trait::async_trait; | |
| 71 | + use axum::Router; | |
| 72 | + use axum::body::Body; | |
| 73 | + use axum::http::{Request, StatusCode}; | |
| 74 | + use axum::routing::get; | |
| 75 | + use std::sync::Arc; | |
| 76 | + use tower::ServiceExt; | |
| 77 | + | |
| 78 | + struct StubResolver { | |
| 79 | + total: u64, | |
| 80 | + active_month: u64, | |
| 81 | + active_halfyear: u64, | |
| 82 | + } | |
| 83 | + | |
| 84 | + #[async_trait] | |
| 85 | + impl LocalUserResolver for StubResolver { | |
| 86 | + async fn find_local_user( | |
| 87 | + &self, | |
| 88 | + _username: &str, | |
| 89 | + ) -> Result<Option<UserSummary>, rustwarden_core::error::AppError> { | |
| 90 | + Ok(None) | |
| 91 | + } | |
| 92 | + async fn count_users_total(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 93 | + Ok(self.total) | |
| 94 | + } | |
| 95 | + async fn count_users_active_since(&self, days: u32) -> Result<u64, rustwarden_core::error::AppError> { | |
| 96 | + Ok(if days <= 30 { | |
| 97 | + self.active_month | |
| 98 | + } else { | |
| 99 | + self.active_halfyear | |
| 100 | + }) | |
| 101 | + } | |
| 102 | + async fn count_local_posts(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 103 | + Ok(0) | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + fn router(domain: &str, total: u64, active_month: u64, active_halfyear: u64) -> Router { | |
| 108 | + let state = FediverseState { | |
| 109 | + config: FediverseConfig::for_tests(domain), | |
| 110 | + resolver: Arc::new(StubResolver { | |
| 111 | + total, | |
| 112 | + active_month, | |
| 113 | + active_halfyear, | |
| 114 | + }), | |
| 115 | + }; | |
| 116 | + Router::new() | |
| 117 | + .route("/.well-known/nodeinfo", get(discovery)) | |
| 118 | + .route("/nodeinfo/2.1", get(detail)) | |
| 119 | + .layer(Extension(state)) | |
| 120 | + } | |
| 121 | + | |
| 122 | + async fn body_json(response: axum::response::Response) -> serde_json::Value { | |
| 123 | + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 124 | + .await | |
| 125 | + .expect("body bytes"); | |
| 126 | + serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 127 | + } | |
| 128 | + | |
| 129 | + #[tokio::test] | |
| 130 | + async fn nodeinfo_discovery_links_to_2_1() { | |
| 131 | + let app = router("example.org", 1, 0, 0); | |
| 132 | + let response = app | |
| 133 | + .oneshot( | |
| 134 | + Request::builder() | |
| 135 | + .uri("/.well-known/nodeinfo") | |
| 136 | + .body(Body::empty()) | |
| 137 | + .unwrap(), | |
| 138 | + ) | |
| 139 | + .await | |
| 140 | + .unwrap(); | |
| 141 | + assert_eq!(response.status(), StatusCode::OK); | |
| 142 | + let body = body_json(response).await; | |
| 143 | + let link = body["links"] | |
| 144 | + .as_array() | |
| 145 | + .expect("links") | |
| 146 | + .iter() | |
| 147 | + .find(|l| l["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1") | |
| 148 | + .expect("2.1 schema link"); | |
| 149 | + assert_eq!(link["href"], "https://example.org/nodeinfo/2.1"); | |
| 150 | + } | |
| 151 | + | |
| 152 | + #[tokio::test] | |
| 153 | + async fn nodeinfo_detail_returns_software_metadata() { | |
| 154 | + let app = router("example.org", 0, 0, 0); | |
| 155 | + let response = app | |
| 156 | + .oneshot( | |
| 157 | + Request::builder() | |
| 158 | + .uri("/nodeinfo/2.1") | |
| 159 | + .body(Body::empty()) | |
| 160 | + .unwrap(), | |
| 161 | + ) | |
| 162 | + .await | |
| 163 | + .unwrap(); | |
| 164 | + assert_eq!(response.status(), StatusCode::OK); | |
| 165 | + let body = body_json(response).await; | |
| 166 | + assert_eq!(body["version"], "2.1"); | |
| 167 | + assert_eq!(body["software"]["name"], "rustwarden"); | |
| 168 | + assert_eq!( | |
| 169 | + body["software"]["version"], | |
| 170 | + env!("CARGO_PKG_VERSION"), | |
| 171 | + "software.version must mirror CARGO_PKG_VERSION" | |
| 172 | + ); | |
| 173 | + assert_eq!(body["protocols"][0], "activitypub"); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[tokio::test] | |
| 177 | + async fn nodeinfo_detail_counts_users_via_resolver() { | |
| 178 | + let app = router("example.org", 42, 7, 23); | |
| 179 | + let response = app | |
| 180 | + .oneshot( | |
| 181 | + Request::builder() | |
| 182 | + .uri("/nodeinfo/2.1") | |
| 183 | + .body(Body::empty()) | |
| 184 | + .unwrap(), | |
| 185 | + ) | |
| 186 | + .await | |
| 187 | + .unwrap(); | |
| 188 | + let body = body_json(response).await; | |
| 189 | + assert_eq!(body["usage"]["users"]["total"], 42); | |
| 190 | + assert_eq!(body["usage"]["users"]["activeMonth"], 7); | |
| 191 | + assert_eq!(body["usage"]["users"]["activeHalfyear"], 23); | |
| 192 | + assert_eq!(body["usage"]["localPosts"], 0); | |
| 193 | + } | |
| 194 | +} |
A
crates/rustwarden-fediverse/src/resolver.rs
+147
-0
@@ -0,0 +1,147 @@
| 1 | +//! `LocalUserResolver` trait + SeaORM-backed default implementation. | |
| 2 | +//! | |
| 3 | +//! Federation handlers depend on this trait rather than directly on SeaORM, | |
| 4 | +//! which keeps unit tests simple (we inject a stub resolver) and lets future | |
| 5 | +//! deployments swap the backing store without touching the handler code. | |
| 6 | + | |
| 7 | +use async_trait::async_trait; | |
| 8 | +use sea_orm::DatabaseConnection; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +use rustwarden_core::error::AppError; | |
| 12 | + | |
| 13 | +/// Minimal projection of a local user usable to render WebFinger and | |
| 14 | +/// Person JSON. Extended by [RFC 0005](../../rfcs/0005-actor-key-storage.md) | |
| 15 | +/// (`actor-key-storage`) with the `ap_public_key_pem` field populated. | |
| 16 | +#[derive(Debug, Clone)] | |
| 17 | +pub struct UserSummary { | |
| 18 | + /// Stable internal user identifier. | |
| 19 | + pub id: Uuid, | |
| 20 | + /// Local username (matches the `alice` part of `@alice@example.org`). | |
| 21 | + pub username: String, | |
| 22 | + /// Optional display name (Mastodon `name` field). | |
| 23 | + pub display_name: Option<String>, | |
| 24 | + /// Optional bio in HTML (Mastodon `summary` field). | |
| 25 | + pub bio: Option<String>, | |
| 26 | + /// PEM-encoded RSA public key. `None` until RFC 0005 lands. | |
| 27 | + pub ap_public_key_pem: Option<String>, | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// Read-only projection of users used by federation handlers. | |
| 31 | +/// | |
| 32 | +/// Concrete implementations provide a thin facade over the underlying user | |
| 33 | +/// store. The default is [`SeaOrmLocalUserResolver`]; tests can inject their | |
| 34 | +/// own implementation. | |
| 35 | +#[async_trait] | |
| 36 | +pub trait LocalUserResolver: Send + Sync { | |
| 37 | + /// Look up a local user by username. | |
| 38 | + /// | |
| 39 | + /// Returns `Ok(None)` when no such user exists or the user is | |
| 40 | + /// soft-deleted. Returns `Err(AppError::Database(_))` on transport errors. | |
| 41 | + async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError>; | |
| 42 | + | |
| 43 | + /// Total number of registered users (NodeInfo `usage.users.total`). | |
| 44 | + async fn count_users_total(&self) -> Result<u64, AppError>; | |
| 45 | + | |
| 46 | + /// Number of users active within the given window. | |
| 47 | + /// | |
| 48 | + /// "Active" is defined as having `last_login_at` within the past | |
| 49 | + /// `days` days. Used to populate `usage.users.active_month` (`days = 30`) | |
| 50 | + /// and `usage.users.active_halfyear` (`days = 180`). | |
| 51 | + async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>; | |
| 52 | + | |
| 53 | + /// Total number of locally-authored posts (NodeInfo `usage.localPosts`). | |
| 54 | + /// Returns `0` until later RFCs introduce post entities. | |
| 55 | + async fn count_local_posts(&self) -> Result<u64, AppError>; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/// Default [`LocalUserResolver`] implementation backed by a SeaORM | |
| 59 | +/// [`DatabaseConnection`]. Cheap to clone (the connection is `Arc`-backed). | |
| 60 | +#[derive(Debug, Clone)] | |
| 61 | +pub struct SeaOrmLocalUserResolver { | |
| 62 | + db: DatabaseConnection, | |
| 63 | +} | |
| 64 | + | |
| 65 | +impl SeaOrmLocalUserResolver { | |
| 66 | + /// Build a resolver around an existing SeaORM connection. | |
| 67 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 68 | + Self { db } | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +#[async_trait] | |
| 73 | +impl LocalUserResolver for SeaOrmLocalUserResolver { | |
| 74 | + async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError> { | |
| 75 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 76 | + | |
| 77 | + let row = rustwarden_core::models::user::Entity::find() | |
| 78 | + .filter(rustwarden_core::models::user::Column::Username.eq(username)) | |
| 79 | + .one(&self.db) | |
| 80 | + .await?; | |
| 81 | + | |
| 82 | + let Some(r) = row else { return Ok(None) }; | |
| 83 | + | |
| 84 | + // RFC 0005: lazy-generate the actor key the first time someone | |
| 85 | + // resolves the user, but only if encryption is configured. We | |
| 86 | + // never block the resolution on a generation failure: better to | |
| 87 | + // serve a key-less actor than to 500 the WebFinger flow. | |
| 88 | + let public_pem = match &r.ap_public_key_pem { | |
| 89 | + Some(pem) => Some(pem.clone()), | |
| 90 | + None => { | |
| 91 | + if rustwarden_core::services::crypto_service::CryptoService::is_available() { | |
| 92 | + match crate::services::actor_key_service::ActorKeyService::ensure_keys( | |
| 93 | + &self.db, r.id, | |
| 94 | + ) | |
| 95 | + .await | |
| 96 | + { | |
| 97 | + Ok(material) => Some(material.public_pem), | |
| 98 | + Err(err) => { | |
| 99 | + tracing::warn!( | |
| 100 | + user_id = %r.id, | |
| 101 | + error = %err, | |
| 102 | + "actor key generation failed, serving key-less actor", | |
| 103 | + ); | |
| 104 | + None | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } else { | |
| 108 | + None | |
| 109 | + } | |
| 110 | + } | |
| 111 | + }; | |
| 112 | + | |
| 113 | + Ok(Some(UserSummary { | |
| 114 | + id: r.id, | |
| 115 | + username: r.username, | |
| 116 | + // `display_name` / `bio` columns are not in the schema yet — a | |
| 117 | + // future RFC will add them. | |
| 118 | + display_name: None, | |
| 119 | + bio: None, | |
| 120 | + ap_public_key_pem: public_pem, | |
| 121 | + })) | |
| 122 | + } | |
| 123 | + | |
| 124 | + async fn count_users_total(&self) -> Result<u64, AppError> { | |
| 125 | + use sea_orm::{EntityTrait, PaginatorTrait}; | |
| 126 | + Ok(rustwarden_core::models::user::Entity::find() | |
| 127 | + .count(&self.db) | |
| 128 | + .await?) | |
| 129 | + } | |
| 130 | + | |
| 131 | + async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError> { | |
| 132 | + use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter}; | |
| 133 | + | |
| 134 | + let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64); | |
| 135 | + Ok(rustwarden_core::models::user::Entity::find() | |
| 136 | + .filter(rustwarden_core::models::user::Column::LastLoginAt.gte(cutoff)) | |
| 137 | + .count(&self.db) | |
| 138 | + .await?) | |
| 139 | + } | |
| 140 | + | |
| 141 | + async fn count_local_posts(&self) -> Result<u64, AppError> { | |
| 142 | + // RFC 0002 — no post entity yet, always zero. Future RFCs that | |
| 143 | + // introduce locally-authored posts will override this method or | |
| 144 | + // ship a dedicated resolver. | |
| 145 | + Ok(0) | |
| 146 | + } | |
| 147 | +} |
A
crates/rustwarden-fediverse/src/routes.rs
+137
-0
@@ -0,0 +1,137 @@
| 1 | +//! Builder for the federation Axum router. | |
| 2 | +//! | |
| 3 | +//! Assembles the four discovery routes (`webfinger`, `nodeinfo` discovery, | |
| 4 | +//! `nodeinfo` detail, actor) into a single `Router` ready to be merged into | |
| 5 | +//! the application by [`crate::app::RustwardenBuilder::build`]. | |
| 6 | + | |
| 7 | +use std::sync::Arc; | |
| 8 | + | |
| 9 | +use axum::{ | |
| 10 | + Extension, Router, | |
| 11 | + routing::{get, post}, | |
| 12 | +}; | |
| 13 | +use sea_orm::DatabaseConnection; | |
| 14 | + | |
| 15 | +use crate::config::FediverseConfig; | |
| 16 | +use rustwarden_core::config::auth::AuthConfig; | |
| 17 | +use crate::services::fediverse_signal_service::{AlwaysAcceptVerifier, SignatureVerifier}; | |
| 18 | + | |
| 19 | +use super::actor; | |
| 20 | +use super::auth_routes::{self, AuthFlowState}; | |
| 21 | +use super::inbox::{self, InboxState}; | |
| 22 | +use super::invite_routes::{self, InviteRoutesState}; | |
| 23 | +use super::nodeinfo; | |
| 24 | +use super::resolver::{LocalUserResolver, SeaOrmLocalUserResolver}; | |
| 25 | +use super::state::FediverseState; | |
| 26 | +use super::webfinger; | |
| 27 | + | |
| 28 | +/// Build the federation router with the default SeaORM-backed resolver | |
| 29 | +/// and the test-only [`AlwaysAcceptVerifier`]. | |
| 30 | +pub fn router(config: FediverseConfig, db: DatabaseConnection) -> Router<DatabaseConnection> { | |
| 31 | + let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone())); | |
| 32 | + // Debug/dev verifier with cache-population side-effect so the | |
| 33 | + // testbed (FEDIVERSE_DEBUG_LOCAL_FEDERATION=true) doesn't need to | |
| 34 | + // manually pre-seed `remote_identities`. | |
| 35 | + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::with_resolver(db)); | |
| 36 | + router_with(config, resolver, verifier) | |
| 37 | +} | |
| 38 | + | |
| 39 | +/// Build the federation router with custom resolver and signature | |
| 40 | +/// verifier implementations. Used by integration tests, consumers that | |
| 41 | +/// already maintain their own user store, or apps wanting to plug a | |
| 42 | +/// production HTTP-Sig verifier (RFC 0012). | |
| 43 | +pub fn router_with( | |
| 44 | + config: FediverseConfig, | |
| 45 | + resolver: Arc<dyn LocalUserResolver>, | |
| 46 | + verifier: Arc<dyn SignatureVerifier>, | |
| 47 | +) -> Router<DatabaseConnection> { | |
| 48 | + let challenge_ttl = config.auth_challenge_ttl(); | |
| 49 | + let state = FediverseState { config, resolver }; | |
| 50 | + let inbox_state = InboxState { | |
| 51 | + verifier: verifier.clone(), | |
| 52 | + }; | |
| 53 | + let auth_flow_state = AuthFlowState { | |
| 54 | + auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())), | |
| 55 | + verifier, | |
| 56 | + challenge_ttl, | |
| 57 | + }; | |
| 58 | + Router::new() | |
| 59 | + .route("/.well-known/webfinger", get(webfinger::handle)) | |
| 60 | + .route("/.well-known/nodeinfo", get(nodeinfo::discovery)) | |
| 61 | + .route("/nodeinfo/2.1", get(nodeinfo::detail)) | |
| 62 | + .route("/users/{username}", get(actor::handle)) | |
| 63 | + .route("/inbox", post(inbox::handle)) | |
| 64 | + .merge(auth_routes::router(auth_flow_state)) | |
| 65 | + .merge(invite_routes::router(InviteRoutesState { | |
| 66 | + default_ttl: std::time::Duration::from_secs(7 * 86400), | |
| 67 | + system_actor_user_id: Arc::new(uuid::Uuid::new_v4), | |
| 68 | + })) | |
| 69 | + .layer(Extension(state)) | |
| 70 | + .layer(Extension(inbox_state)) | |
| 71 | +} | |
| 72 | + | |
| 73 | +/// Build the federation router with explicit `AuthConfig` injection. | |
| 74 | +/// Use this entry point when the consumer cannot rely on env vars | |
| 75 | +/// being set at router-build time. | |
| 76 | +pub fn router_with_auth( | |
| 77 | + config: FediverseConfig, | |
| 78 | + resolver: Arc<dyn LocalUserResolver>, | |
| 79 | + verifier: Arc<dyn SignatureVerifier>, | |
| 80 | + auth_config: Arc<AuthConfig>, | |
| 81 | +) -> Router<DatabaseConnection> { | |
| 82 | + let challenge_ttl = config.auth_challenge_ttl(); | |
| 83 | + let state = FediverseState { config, resolver }; | |
| 84 | + let inbox_state = InboxState { | |
| 85 | + verifier: verifier.clone(), | |
| 86 | + }; | |
| 87 | + let auth_flow_state = AuthFlowState { | |
| 88 | + auth_config, | |
| 89 | + verifier, | |
| 90 | + challenge_ttl, | |
| 91 | + }; | |
| 92 | + Router::new() | |
| 93 | + .route("/.well-known/webfinger", get(webfinger::handle)) | |
| 94 | + .route("/.well-known/nodeinfo", get(nodeinfo::discovery)) | |
| 95 | + .route("/nodeinfo/2.1", get(nodeinfo::detail)) | |
| 96 | + .route("/users/{username}", get(actor::handle)) | |
| 97 | + .route("/inbox", post(inbox::handle)) | |
| 98 | + .merge(auth_routes::router(auth_flow_state)) | |
| 99 | + .merge(invite_routes::router(InviteRoutesState { | |
| 100 | + default_ttl: std::time::Duration::from_secs(7 * 86400), | |
| 101 | + system_actor_user_id: Arc::new(uuid::Uuid::new_v4), | |
| 102 | + })) | |
| 103 | + .layer(Extension(state)) | |
| 104 | + .layer(Extension(inbox_state)) | |
| 105 | +} | |
| 106 | + | |
| 107 | +fn placeholder_auth_config() -> AuthConfig { | |
| 108 | + // Env vars not set at router-build time — use harmless defaults. | |
| 109 | + // Production callers should always go through `with_fediverse()` | |
| 110 | + // which loads `AuthConfig` once at app startup. | |
| 111 | + AuthConfig { | |
| 112 | + jwt_secret: "0".repeat(64), | |
| 113 | + jwt_expiration_minutes: 15, | |
| 114 | + jwt_issuer: "rustwarden".to_string(), | |
| 115 | + refresh_token_expiration_days: 7, | |
| 116 | + remember_me_expiration_days: 30, | |
| 117 | + session_timeout_minutes: 30, | |
| 118 | + session_backend: rustwarden_core::config::auth::SessionBackend::Memory, | |
| 119 | + redis_url: None, | |
| 120 | + rate_limit_login_per_minute: 5, | |
| 121 | + rate_limit_refresh_per_minute: 10, | |
| 122 | + rate_limit_general_per_minute: 100, | |
| 123 | + cookie_secure: false, | |
| 124 | + cookie_same_site: "Strict".to_string(), | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +/// Backwards-compatible alias for [`router_with`] kept until the next | |
| 129 | +/// major version; new code should call `router_with` directly. | |
| 130 | +#[deprecated(since = "0.2.0", note = "use `router_with` (RFC 0012)")] | |
| 131 | +pub fn router_with_resolver( | |
| 132 | + config: FediverseConfig, | |
| 133 | + resolver: Arc<dyn LocalUserResolver>, | |
| 134 | +) -> Router<DatabaseConnection> { | |
| 135 | + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::new()); | |
| 136 | + router_with(config, resolver, verifier) | |
| 137 | +} |
A
crates/rustwarden-fediverse/src/services/actor_key_service.rs
+404
-0
@@ -0,0 +1,404 @@
| 1 | +//! ActivityPub actor-key generation and storage (RFC 0005). | |
| 2 | +//! | |
| 3 | +//! Generates RSA-2048 PKCS#8 / SPKI key pairs for local users on demand, | |
| 4 | +//! encrypts the private half via [`rustwarden_core::services::crypto_service::CryptoService`], | |
| 5 | +//! and persists both halves on the `users` row. The public PEM is the | |
| 6 | +//! one Mastodon and other ActivityPub servers read from | |
| 7 | +//! `/users/{username}` to verify HTTP signatures. | |
| 8 | +//! | |
| 9 | +//! Gated by `experimental-fediverse`. | |
| 10 | + | |
| 11 | +use rsa::pkcs1::EncodeRsaPublicKey; | |
| 12 | +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding}; | |
| 13 | +use rsa::rand_core::OsRng; | |
| 14 | +use rsa::{RsaPrivateKey, RsaPublicKey}; | |
| 15 | +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; | |
| 16 | +use uuid::Uuid; | |
| 17 | + | |
| 18 | +use rustwarden_core::error::AppError; | |
| 19 | +use rustwarden_core::models::user; | |
| 20 | +use rustwarden_core::services::crypto_service::CryptoService; | |
| 21 | + | |
| 22 | +/// RSA modulus size for ActivityPub actor keys. Mastodon requires 2048. | |
| 23 | +const RSA_BITS: usize = 2048; | |
| 24 | + | |
| 25 | +/// Bundle returned by [`ActorKeyService::ensure_keys`]. | |
| 26 | +/// | |
| 27 | +/// The private PEM in this struct is **decrypted** so that signing code | |
| 28 | +/// can use it directly. It is never written to disk in this form. | |
| 29 | +#[derive(Debug, Clone)] | |
| 30 | +pub struct ActorKeyMaterial { | |
| 31 | + /// Public key as PEM (SPKI / `-----BEGIN PUBLIC KEY-----`). | |
| 32 | + pub public_pem: String, | |
| 33 | + /// Private key as PEM (PKCS#8 / `-----BEGIN PRIVATE KEY-----`). | |
| 34 | + pub private_pem: String, | |
| 35 | +} | |
| 36 | + | |
| 37 | +/// RSA actor-key generator and persistence layer for local users. | |
| 38 | +/// | |
| 39 | +/// All methods are stateless — the service owns no connection. | |
| 40 | +pub struct ActorKeyService; | |
| 41 | + | |
| 42 | +impl ActorKeyService { | |
| 43 | + /// Generate a fresh RSA-2048 key pair encoded as PEM. | |
| 44 | + /// | |
| 45 | + /// CPU-bound (~50–200 ms). Async callers should wrap this in | |
| 46 | + /// `tokio::task::spawn_blocking` to keep the runtime responsive. | |
| 47 | + pub fn generate() -> Result<ActorKeyMaterial, AppError> { | |
| 48 | + let mut rng = OsRng; | |
| 49 | + let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS) | |
| 50 | + .map_err(|e| AppError::Internal(format!("RSA key generation failed: {e}")))?; | |
| 51 | + let public_key = RsaPublicKey::from(&private_key); | |
| 52 | + | |
| 53 | + let private_pem = private_key | |
| 54 | + .to_pkcs8_pem(LineEnding::LF) | |
| 55 | + .map_err(|e| AppError::Internal(format!("PKCS#8 encode failed: {e}")))? | |
| 56 | + .to_string(); | |
| 57 | + let public_pem = public_key | |
| 58 | + .to_public_key_pem(LineEnding::LF) | |
| 59 | + .map_err(|e| AppError::Internal(format!("SPKI encode failed: {e}")))?; | |
| 60 | + | |
| 61 | + // Touch the PKCS#1 import so the dependency stays linked even if | |
| 62 | + // a refactor removes the explicit use elsewhere; cheap correctness | |
| 63 | + // check that both encoders agree on the key. | |
| 64 | + let _ = public_key | |
| 65 | + .to_pkcs1_pem(LineEnding::LF) | |
| 66 | + .map_err(|e| AppError::Internal(format!("PKCS#1 encode failed: {e}")))?; | |
| 67 | + | |
| 68 | + Ok(ActorKeyMaterial { | |
| 69 | + public_pem, | |
| 70 | + private_pem, | |
| 71 | + }) | |
| 72 | + } | |
| 73 | + | |
| 74 | + /// Read-only lookup of the persisted public PEM. Returns `Ok(None)` | |
| 75 | + /// when the user exists but has no key yet, and `AppError::NotFound` | |
| 76 | + /// when the user does not exist. | |
| 77 | + pub async fn public_key_for_user( | |
| 78 | + db: &DatabaseConnection, | |
| 79 | + user_id: Uuid, | |
| 80 | + ) -> Result<Option<String>, AppError> { | |
| 81 | + let model = user::Entity::find_by_id(user_id) | |
| 82 | + .one(db) | |
| 83 | + .await? | |
| 84 | + .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; | |
| 85 | + Ok(model.ap_public_key_pem) | |
| 86 | + } | |
| 87 | + | |
| 88 | + /// Idempotent: returns existing material if both columns are set, | |
| 89 | + /// otherwise generates a fresh pair, encrypts the private half via | |
| 90 | + /// [`CryptoService`], and persists both columns. The returned bundle | |
| 91 | + /// always carries the **decrypted** private PEM. | |
| 92 | + /// | |
| 93 | + /// Returns [`AppError::Config`] when no encryption key is configured, | |
| 94 | + /// because storing the private half in clear is forbidden. | |
| 95 | + pub async fn ensure_keys( | |
| 96 | + db: &DatabaseConnection, | |
| 97 | + user_id: Uuid, | |
| 98 | + ) -> Result<ActorKeyMaterial, AppError> { | |
| 99 | + let model = user::Entity::find_by_id(user_id) | |
| 100 | + .one(db) | |
| 101 | + .await? | |
| 102 | + .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?; | |
| 103 | + | |
| 104 | + if let (Some(public_pem), Some(encrypted_private)) = | |
| 105 | + (model.ap_public_key_pem.clone(), model.ap_private_key_pem.clone()) | |
| 106 | + { | |
| 107 | + let private_pem = CryptoService::decrypt(&encrypted_private)?; | |
| 108 | + return Ok(ActorKeyMaterial { | |
| 109 | + public_pem, | |
| 110 | + private_pem, | |
| 111 | + }); | |
| 112 | + } | |
| 113 | + | |
| 114 | + if !CryptoService::is_available() { | |
| 115 | + return Err(AppError::Config( | |
| 116 | + "ENCRYPTION_KEY must be configured before generating actor keys".to_string(), | |
| 117 | + )); | |
| 118 | + } | |
| 119 | + | |
| 120 | + let material = Self::generate()?; | |
| 121 | + let encrypted_private = CryptoService::encrypt(&material.private_pem)?; | |
| 122 | + | |
| 123 | + let mut active: user::ActiveModel = model.into(); | |
| 124 | + active.ap_public_key_pem = Set(Some(material.public_pem.clone())); | |
| 125 | + active.ap_private_key_pem = Set(Some(encrypted_private)); | |
| 126 | + active.update(db).await?; | |
| 127 | + | |
| 128 | + Ok(material) | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +#[cfg(test)] | |
| 133 | +// `ENV_LOCK` is intentionally held across `.await` so that two | |
| 134 | +// async tests cannot trample each other's `ENCRYPTION_KEY` env var | |
| 135 | +// while a SeaORM mock query is in flight. Tests on a `MockDatabase` | |
| 136 | +// never block; the lock is in practice released within microseconds. | |
| 137 | +#[allow(clippy::await_holding_lock)] | |
| 138 | +mod tests { | |
| 139 | + use super::*; | |
| 140 | + use chrono::Utc; | |
| 141 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 142 | + use std::sync::Mutex; | |
| 143 | + | |
| 144 | + /// Serialise tests that mutate `ENCRYPTION_KEY` so that one test setting | |
| 145 | + /// the env var does not race with another test clearing it. | |
| 146 | + static ENV_LOCK: Mutex<()> = Mutex::new(()); | |
| 147 | + | |
| 148 | + fn user_row( | |
| 149 | + id: Uuid, | |
| 150 | + public: Option<String>, | |
| 151 | + private: Option<String>, | |
| 152 | + ) -> user::Model { | |
| 153 | + let now = Utc::now(); | |
| 154 | + user::Model { | |
| 155 | + id, | |
| 156 | + username: "alice".to_string(), | |
| 157 | + email: "alice@example.com".to_string(), | |
| 158 | + password_hash: "$2b$12$placeholder".to_string(), | |
| 159 | + must_change_password: false, | |
| 160 | + last_login_at: None, | |
| 161 | + last_login_ip: None, | |
| 162 | + password_changed_at: None, | |
| 163 | + email_verified: true, | |
| 164 | + email_verified_at: None, | |
| 165 | + created_at: now, | |
| 166 | + updated_at: now, | |
| 167 | + ap_public_key_pem: public, | |
| 168 | + ap_private_key_pem: private, | |
| 169 | + ap_discoverable: true, | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + fn set_test_encryption_key() { | |
| 174 | + // 64 hex chars => 32 bytes => AES-256 key. | |
| 175 | + // SAFETY: tests run sequentially (--test-threads=1 in CI for this crate). | |
| 176 | + unsafe { | |
| 177 | + std::env::set_var( | |
| 178 | + "ENCRYPTION_KEY", | |
| 179 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 180 | + ); | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + fn clear_encryption_key() { | |
| 185 | + // SAFETY: tests run sequentially. | |
| 186 | + unsafe { | |
| 187 | + std::env::remove_var("ENCRYPTION_KEY"); | |
| 188 | + std::env::remove_var("OAUTH_ENCRYPTION_KEY"); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + // ─── Pure (generation + serialization) ─────────────────────────── | |
| 193 | + | |
| 194 | + #[test] | |
| 195 | + fn generate_returns_valid_pem_pair() { | |
| 196 | + let material = ActorKeyService::generate().expect("RSA gen must succeed"); | |
| 197 | + assert!( | |
| 198 | + material.public_pem.contains("BEGIN PUBLIC KEY"), | |
| 199 | + "public PEM header missing: {}", | |
| 200 | + material.public_pem | |
| 201 | + ); | |
| 202 | + assert!( | |
| 203 | + material.private_pem.contains("BEGIN PRIVATE KEY"), | |
| 204 | + "private PEM header missing" | |
| 205 | + ); | |
| 206 | + } | |
| 207 | + | |
| 208 | + #[test] | |
| 209 | + fn generate_keys_are_2048_bits() { | |
| 210 | + use rsa::pkcs8::DecodePrivateKey; | |
| 211 | + use rsa::traits::PublicKeyParts; | |
| 212 | + let material = ActorKeyService::generate().expect("RSA gen must succeed"); | |
| 213 | + let key = RsaPrivateKey::from_pkcs8_pem(&material.private_pem) | |
| 214 | + .expect("decode generated private PEM"); | |
| 215 | + assert_eq!(key.n().bits(), RSA_BITS); | |
| 216 | + } | |
| 217 | + | |
| 218 | + #[test] | |
| 219 | + fn generate_returns_distinct_pairs() { | |
| 220 | + let a = ActorKeyService::generate().unwrap(); | |
| 221 | + let b = ActorKeyService::generate().unwrap(); | |
| 222 | + assert_ne!(a.public_pem, b.public_pem); | |
| 223 | + assert_ne!(a.private_pem, b.private_pem); | |
| 224 | + } | |
| 225 | + | |
| 226 | + #[test] | |
| 227 | + fn actor_key_material_clone_works() { | |
| 228 | + let material = ActorKeyMaterial { | |
| 229 | + public_pem: "pub".into(), | |
| 230 | + private_pem: "priv".into(), | |
| 231 | + }; | |
| 232 | + let cloned = material.clone(); | |
| 233 | + assert_eq!(cloned.public_pem, "pub"); | |
| 234 | + assert_eq!(cloned.private_pem, "priv"); | |
| 235 | + } | |
| 236 | + | |
| 237 | + // ─── DB-mocked (sea-orm/mock) ──────────────────────────────────── | |
| 238 | + | |
| 239 | + #[tokio::test] | |
| 240 | + async fn public_key_for_user_returns_none_when_unset() { | |
| 241 | + let user_id = Uuid::new_v4(); | |
| 242 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 243 | + .append_query_results([vec![user_row(user_id, None, None)]]) | |
| 244 | + .into_connection(); | |
| 245 | + | |
| 246 | + let pem = ActorKeyService::public_key_for_user(&db, user_id) | |
| 247 | + .await | |
| 248 | + .expect("query ok"); | |
| 249 | + assert!(pem.is_none()); | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[tokio::test] | |
| 253 | + async fn public_key_for_user_returns_some_when_set() { | |
| 254 | + let user_id = Uuid::new_v4(); | |
| 255 | + let pem = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----\n".to_string(); | |
| 256 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 257 | + .append_query_results([vec![user_row(user_id, Some(pem.clone()), None)]]) | |
| 258 | + .into_connection(); | |
| 259 | + | |
| 260 | + let result = ActorKeyService::public_key_for_user(&db, user_id) | |
| 261 | + .await | |
| 262 | + .expect("query ok"); | |
| 263 | + assert_eq!(result.as_deref(), Some(pem.as_str())); | |
| 264 | + } | |
| 265 | + | |
| 266 | + #[tokio::test] | |
| 267 | + async fn public_key_for_user_returns_error_when_user_missing() { | |
| 268 | + let user_id = Uuid::new_v4(); | |
| 269 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 270 | + .append_query_results([Vec::<user::Model>::new()]) | |
| 271 | + .into_connection(); | |
| 272 | + | |
| 273 | + let err = ActorKeyService::public_key_for_user(&db, user_id) | |
| 274 | + .await | |
| 275 | + .expect_err("must surface NotFound"); | |
| 276 | + assert!(matches!(err, AppError::NotFound(_))); | |
| 277 | + } | |
| 278 | + | |
| 279 | + #[tokio::test] | |
| 280 | + async fn ensure_keys_returns_existing_when_present() { | |
| 281 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 282 | + set_test_encryption_key(); | |
| 283 | + let user_id = Uuid::new_v4(); | |
| 284 | + let public_pem = "-----BEGIN PUBLIC KEY-----\nstored\n-----END PUBLIC KEY-----\n"; | |
| 285 | + let plaintext_priv = "-----BEGIN PRIVATE KEY-----\nstored-priv\n-----END PRIVATE KEY-----\n"; | |
| 286 | + let encrypted_priv = CryptoService::encrypt(plaintext_priv).expect("encrypt"); | |
| 287 | + | |
| 288 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 289 | + .append_query_results([vec![user_row( | |
| 290 | + user_id, | |
| 291 | + Some(public_pem.to_string()), | |
| 292 | + Some(encrypted_priv), | |
| 293 | + )]]) | |
| 294 | + .into_connection(); | |
| 295 | + | |
| 296 | + let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 297 | + .await | |
| 298 | + .expect("ensure_keys ok"); | |
| 299 | + assert_eq!(material.public_pem, public_pem); | |
| 300 | + assert_eq!(material.private_pem, plaintext_priv); | |
| 301 | + } | |
| 302 | + | |
| 303 | + #[tokio::test] | |
| 304 | + async fn ensure_keys_generates_when_missing() { | |
| 305 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 306 | + set_test_encryption_key(); | |
| 307 | + let user_id = Uuid::new_v4(); | |
| 308 | + let initial = user_row(user_id, None, None); | |
| 309 | + | |
| 310 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 311 | + // 1st: SELECT in ensure_keys | |
| 312 | + .append_query_results([vec![initial.clone()]]) | |
| 313 | + // 2nd: SELECT issued by .update() to load fresh state | |
| 314 | + .append_query_results([vec![initial]]) | |
| 315 | + .append_exec_results([MockExecResult { | |
| 316 | + last_insert_id: 0, | |
| 317 | + rows_affected: 1, | |
| 318 | + }]) | |
| 319 | + .into_connection(); | |
| 320 | + | |
| 321 | + let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 322 | + .await | |
| 323 | + .expect("ensure_keys ok"); | |
| 324 | + assert!(material.public_pem.contains("BEGIN PUBLIC KEY")); | |
| 325 | + assert!(material.private_pem.contains("BEGIN PRIVATE KEY")); | |
| 326 | + } | |
| 327 | + | |
| 328 | + #[tokio::test] | |
| 329 | + async fn ensure_keys_persists_encrypted_private_key() { | |
| 330 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 331 | + set_test_encryption_key(); | |
| 332 | + let user_id = Uuid::new_v4(); | |
| 333 | + let initial = user_row(user_id, None, None); | |
| 334 | + | |
| 335 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 336 | + .append_query_results([vec![initial.clone()]]) | |
| 337 | + .append_query_results([vec![initial]]) | |
| 338 | + .append_exec_results([MockExecResult { | |
| 339 | + last_insert_id: 0, | |
| 340 | + rows_affected: 1, | |
| 341 | + }]) | |
| 342 | + .into_connection(); | |
| 343 | + | |
| 344 | + let material = ActorKeyService::ensure_keys(&db, user_id) | |
| 345 | + .await | |
| 346 | + .unwrap(); | |
| 347 | + // Sanity: round-trip via CryptoService confirms the private PEM | |
| 348 | + // returned to the caller is decrypted, while the value the | |
| 349 | + // service handed to the DB layer was `enc:`-prefixed. | |
| 350 | + let re_encrypted = CryptoService::encrypt(&material.private_pem).unwrap(); | |
| 351 | + assert!(re_encrypted.starts_with("enc:")); | |
| 352 | + } | |
| 353 | + | |
| 354 | + #[tokio::test] | |
| 355 | + async fn ensure_keys_returns_decrypted_private_pem() { | |
| 356 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 357 | + set_test_encryption_key(); | |
| 358 | + let user_id = Uuid::new_v4(); | |
| 359 | + let initial = user_row(user_id, None, None); | |
| 360 | + | |
| 361 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 362 | + .append_query_results([vec![initial.clone()]]) | |
| 363 | + .append_query_results([vec![initial]]) | |
| 364 | + .append_exec_results([MockExecResult { | |
| 365 | + last_insert_id: 0, | |
| 366 | + rows_affected: 1, | |
| 367 | + }]) | |
| 368 | + .into_connection(); | |
| 369 | + | |
| 370 | + let material = ActorKeyService::ensure_keys(&db, user_id).await.unwrap(); | |
| 371 | + assert!(material.private_pem.starts_with("-----BEGIN PRIVATE KEY-----")); | |
| 372 | + assert!(material.private_pem.contains("-----END PRIVATE KEY-----")); | |
| 373 | + } | |
| 374 | + | |
| 375 | + #[tokio::test] | |
| 376 | + async fn ensure_keys_errors_when_encryption_key_missing() { | |
| 377 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 378 | + clear_encryption_key(); | |
| 379 | + let user_id = Uuid::new_v4(); | |
| 380 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 381 | + .append_query_results([vec![user_row(user_id, None, None)]]) | |
| 382 | + .into_connection(); | |
| 383 | + | |
| 384 | + let err = ActorKeyService::ensure_keys(&db, user_id) | |
| 385 | + .await | |
| 386 | + .expect_err("must error without encryption key"); | |
| 387 | + assert!(matches!(err, AppError::Config(_))); | |
| 388 | + } | |
| 389 | + | |
| 390 | + #[tokio::test] | |
| 391 | + async fn ensure_keys_returns_error_when_user_missing() { | |
| 392 | + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); | |
| 393 | + set_test_encryption_key(); | |
| 394 | + let user_id = Uuid::new_v4(); | |
| 395 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 396 | + .append_query_results([Vec::<user::Model>::new()]) | |
| 397 | + .into_connection(); | |
| 398 | + | |
| 399 | + let err = ActorKeyService::ensure_keys(&db, user_id) | |
| 400 | + .await | |
| 401 | + .expect_err("must surface NotFound"); | |
| 402 | + assert!(matches!(err, AppError::NotFound(_))); | |
| 403 | + } | |
| 404 | +} |
A
crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs
+952
-0
@@ -0,0 +1,952 @@
| 1 | +//! AP-native authentication flow service (RFC 0006). | |
| 2 | +//! | |
| 3 | +//! Orchestrates the four-step challenge state machine on the | |
| 4 | +//! client-side (`b.tld`): | |
| 5 | +//! | |
| 6 | +//! 1. `initiate` — persist a fresh `pending` challenge with a 256-bit | |
| 7 | +//! cryptographic nonce. | |
| 8 | +//! 2. `record_decision` — IdP-side approve/reject promotes the row. | |
| 9 | +//! 3. `status` — caller polls; expired rows transition to `Expired`. | |
| 10 | +//! 4. `consume` — mint a remote JWT from an `Approved` challenge, | |
| 11 | +//! flipping the row to `Consumed` (one-shot). | |
| 12 | +//! | |
| 13 | +//! Gated by `experimental-fediverse`. | |
| 14 | + | |
| 15 | +use base64::Engine; | |
| 16 | +use chrono::{DateTime, Duration as ChronoDuration, Utc}; | |
| 17 | +use rand::RngCore; | |
| 18 | +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; | |
| 19 | +use std::time::Duration; | |
| 20 | +use uuid::Uuid; | |
| 21 | + | |
| 22 | +use crate::config::FediverseConfig; | |
| 23 | +use rustwarden_core::auth::jwt::JwtService; | |
| 24 | +use rustwarden_core::config::auth::AuthConfig; | |
| 25 | +use rustwarden_core::error::AppError; | |
| 26 | +use crate::models::fediverse_auth_challenge; | |
| 27 | + | |
| 28 | +/// State machine for [`fediverse_auth_challenge::Model::status`]. | |
| 29 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 30 | +pub enum ChallengeStatus { | |
| 31 | + /// Awaiting IdP decision. | |
| 32 | + Pending, | |
| 33 | + /// IdP user clicked Approve. | |
| 34 | + Approved, | |
| 35 | + /// IdP user clicked Reject. | |
| 36 | + Rejected, | |
| 37 | + /// `expires_at` reached without decision. | |
| 38 | + Expired, | |
| 39 | + /// Already exchanged for a JWT (one-shot guard). | |
| 40 | + Consumed, | |
| 41 | +} | |
| 42 | + | |
| 43 | +impl ChallengeStatus { | |
| 44 | + /// Wire form stored in the `status` column. | |
| 45 | + pub fn as_str(self) -> &'static str { | |
| 46 | + match self { | |
| 47 | + Self::Pending => "pending", | |
| 48 | + Self::Approved => "approved", | |
| 49 | + Self::Rejected => "rejected", | |
| 50 | + Self::Expired => "expired", | |
| 51 | + Self::Consumed => "consumed", | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Best-effort parse — unknown values fall back to `Pending` so a | |
| 56 | + /// data corruption never silently mints a JWT. | |
| 57 | + pub fn parse(raw: &str) -> Self { | |
| 58 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 59 | + "approved" => Self::Approved, | |
| 60 | + "rejected" => Self::Rejected, | |
| 61 | + "expired" => Self::Expired, | |
| 62 | + "consumed" => Self::Consumed, | |
| 63 | + _ => Self::Pending, | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Caller input for [`FediverseAuthService::initiate`]. | |
| 69 | +#[derive(Debug, Clone)] | |
| 70 | +pub struct InitiateRequest { | |
| 71 | + /// `@user@domain` handle the user typed. | |
| 72 | + pub handle: String, | |
| 73 | + /// Optional URL the caller wants the browser redirected to after | |
| 74 | + /// the JWT is minted. | |
| 75 | + pub redirect_url: Option<String>, | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// Receipt returned by [`FediverseAuthService::initiate`]. | |
| 79 | +#[derive(Debug, Clone)] | |
| 80 | +pub struct InitiatedChallenge { | |
| 81 | + /// PK of the persisted row. | |
| 82 | + pub id: Uuid, | |
| 83 | + /// Cryptographic nonce embedded in the AP `AuthRequest` body. | |
| 84 | + pub nonce: String, | |
| 85 | + /// Hard deadline. | |
| 86 | + pub expires_at: DateTime<Utc>, | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// IdP-side input for [`FediverseAuthService::record_decision`]. | |
| 90 | +#[derive(Debug, Clone)] | |
| 91 | +pub enum ChallengeDecision { | |
| 92 | + /// User approved; bind the resolved `remote_identities.id` to the | |
| 93 | + /// challenge so that `consume` can mint the JWT. | |
| 94 | + Approve { | |
| 95 | + /// FK of the resolved remote identity bound to this approve. | |
| 96 | + remote_identity_id: Uuid, | |
| 97 | + }, | |
| 98 | + /// User rejected. | |
| 99 | + Reject, | |
| 100 | +} | |
| 101 | + | |
| 102 | +/// Output of [`FediverseAuthService::consume`]. | |
| 103 | +#[derive(Debug, Clone)] | |
| 104 | +pub struct ConsumedSession { | |
| 105 | + /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`). | |
| 106 | + pub jwt: String, | |
| 107 | + /// FK of the federated identity the JWT speaks for. | |
| 108 | + pub remote_identity_id: Uuid, | |
| 109 | + /// `@user@domain` handle for log/audit cohesion. | |
| 110 | + pub handle: String, | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Stateless service exposing the AP-native auth state machine. | |
| 114 | +pub struct FediverseAuthService; | |
| 115 | + | |
| 116 | +impl FediverseAuthService { | |
| 117 | + /// Step 1 — persist a `pending` challenge for the given handle. | |
| 118 | + /// | |
| 119 | + /// The caller is responsible for separately enqueueing the AP | |
| 120 | + /// `AuthRequest` activity to the remote inbox via | |
| 121 | + /// `FediverseOutboundService::enqueue` and the helper | |
| 122 | + /// [`build_auth_request_payload`] below. | |
| 123 | + pub async fn initiate( | |
| 124 | + db: &DatabaseConnection, | |
| 125 | + request: InitiateRequest, | |
| 126 | + ttl: Duration, | |
| 127 | + ) -> Result<InitiatedChallenge, AppError> { | |
| 128 | + let domain = handle_domain(&request.handle)?; | |
| 129 | + let now = Utc::now(); | |
| 130 | + let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { | |
| 131 | + AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) | |
| 132 | + })?; | |
| 133 | + let nonce = generate_nonce(); | |
| 134 | + | |
| 135 | + let id = Uuid::new_v4(); | |
| 136 | + let active = fediverse_auth_challenge::ActiveModel { | |
| 137 | + id: Set(id), | |
| 138 | + nonce: Set(nonce.clone()), | |
| 139 | + handle: Set(request.handle.clone()), | |
| 140 | + domain: Set(domain), | |
| 141 | + requested_remote_id: Set(None), | |
| 142 | + status: Set(ChallengeStatus::Pending.as_str().to_string()), | |
| 143 | + redirect_url: Set(request.redirect_url), | |
| 144 | + created_at: Set(now), | |
| 145 | + expires_at: Set(expires_at), | |
| 146 | + consumed_at: Set(None), | |
| 147 | + }; | |
| 148 | + let inserted = active.insert(db).await?; | |
| 149 | + | |
| 150 | + Ok(InitiatedChallenge { | |
| 151 | + id: inserted.id, | |
| 152 | + nonce: inserted.nonce, | |
| 153 | + expires_at: inserted.expires_at, | |
| 154 | + }) | |
| 155 | + } | |
| 156 | + | |
| 157 | + /// Step 2 — IdP records the user's decision. | |
| 158 | + pub async fn record_decision( | |
| 159 | + db: &DatabaseConnection, | |
| 160 | + challenge_id: Uuid, | |
| 161 | + decision: ChallengeDecision, | |
| 162 | + ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 163 | + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 164 | + .one(db) | |
| 165 | + .await? | |
| 166 | + .ok_or_else(|| { | |
| 167 | + AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 168 | + })?; | |
| 169 | + | |
| 170 | + if model.status != ChallengeStatus::Pending.as_str() { | |
| 171 | + return Err(AppError::Validation(format!( | |
| 172 | + "Cannot record decision: challenge is in state {}", | |
| 173 | + model.status | |
| 174 | + ))); | |
| 175 | + } | |
| 176 | + | |
| 177 | + let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 178 | + match decision { | |
| 179 | + ChallengeDecision::Approve { remote_identity_id } => { | |
| 180 | + active.status = Set(ChallengeStatus::Approved.as_str().to_string()); | |
| 181 | + active.requested_remote_id = Set(Some(remote_identity_id)); | |
| 182 | + } | |
| 183 | + ChallengeDecision::Reject => { | |
| 184 | + active.status = Set(ChallengeStatus::Rejected.as_str().to_string()); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + Ok(active.update(db).await?) | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// Step 1bis (RFC 0006 Phase 2 — inbox enrichment) : called by the | |
| 191 | + /// IdP-side `/fediverse/auth/inbox` handler **after** the signature | |
| 192 | + /// has been verified. Parses the AP `AuthRequest` body, resolves the | |
| 193 | + /// requester's `remote_identities.id` from the `actor` URL, and | |
| 194 | + /// inserts a `Pending` challenge bound to that requester. | |
| 195 | + /// | |
| 196 | + /// Without this step, the consent screen (RFC 0023 Slice B iter. 4) | |
| 197 | + /// cannot Approve : `record_decision` requires `requested_remote_id` | |
| 198 | + /// which used to stay `None` because the inbox handler only | |
| 199 | + /// acknowledged the activity. | |
| 200 | + /// | |
| 201 | + /// Returns the inserted row. The IdP UI can then show | |
| 202 | + /// `/fediverse/consent/{returned.id}` to the local user. | |
| 203 | + /// | |
| 204 | + /// **Validation** : | |
| 205 | + /// - `body.target` must equal `@<local_user>@<idp_domain>` — | |
| 206 | + /// otherwise `AppError::Validation`. | |
| 207 | + /// - `body.actor` must point to an existing `remote_identities` row — | |
| 208 | + /// the row is created upstream by the signature verifier | |
| 209 | + /// ([`crate::fediverse::http_signatures::HttpSigVerifier::with_resolver`]) | |
| 210 | + /// when the actor doc is fetched the first time. | |
| 211 | + pub async fn persist_inbound_request( | |
| 212 | + db: &DatabaseConnection, | |
| 213 | + body: &serde_json::Value, | |
| 214 | + idp_domain: &str, | |
| 215 | + ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 216 | + use crate::models::remote_identity; | |
| 217 | + use sea_orm::QueryFilter; | |
| 218 | + | |
| 219 | + let target = body | |
| 220 | + .get("target") | |
| 221 | + .and_then(|v| v.as_str()) | |
| 222 | + .ok_or_else(|| AppError::Validation("AuthRequest body missing `target`".into()))? | |
| 223 | + .to_string(); | |
| 224 | + let actor_url = body | |
| 225 | + .get("actor") | |
| 226 | + .and_then(|v| v.as_str()) | |
| 227 | + .ok_or_else(|| AppError::Validation("AuthRequest body missing `actor`".into()))? | |
| 228 | + .to_string(); | |
| 229 | + let nonce = body | |
| 230 | + .get("nonce") | |
| 231 | + .and_then(|v| v.as_str()) | |
| 232 | + .ok_or_else(|| AppError::Validation("AuthRequest body missing `nonce`".into()))? | |
| 233 | + .to_string(); | |
| 234 | + | |
| 235 | + // Validate target domain matches the IdP — otherwise the request | |
| 236 | + // is misrouted and we refuse rather than create a stray challenge. | |
| 237 | + let target_domain = handle_domain(&target)?; | |
| 238 | + if target_domain != idp_domain.to_ascii_lowercase() { | |
| 239 | + return Err(AppError::Validation(format!( | |
| 240 | + "AuthRequest target domain `{target_domain}` does not match this IdP `{idp_domain}`", | |
| 241 | + ))); | |
| 242 | + } | |
| 243 | + | |
| 244 | + // Resolve the requester's remote_identities row by AP id. The | |
| 245 | + // signature verifier ran upstream and already required this row, | |
| 246 | + // so a NotFound here means a code-ordering bug and we surface it. | |
| 247 | + use sea_orm::ColumnTrait; | |
| 248 | + let requester = remote_identity::Entity::find() | |
| 249 | + .filter(remote_identity::Column::ApId.eq(&actor_url)) | |
| 250 | + .one(db) | |
| 251 | + .await? | |
| 252 | + .ok_or_else(|| { | |
| 253 | + AppError::Validation(format!( | |
| 254 | + "Requester actor `{actor_url}` not found in remote_identities cache; \ | |
| 255 | + signature verifier should have populated this row", | |
| 256 | + )) | |
| 257 | + })?; | |
| 258 | + | |
| 259 | + // Parse `expiresAt` (RFC3339); fallback to default TTL if absent | |
| 260 | + // or malformed (5 min, RFC 0006 §6). | |
| 261 | + let expires_at = body | |
| 262 | + .get("expiresAt") | |
| 263 | + .and_then(|v| v.as_str()) | |
| 264 | + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) | |
| 265 | + .map(|dt| dt.with_timezone(&Utc)) | |
| 266 | + .unwrap_or_else(|| Utc::now() + ChronoDuration::minutes(5)); | |
| 267 | + | |
| 268 | + let id = Uuid::new_v4(); | |
| 269 | + let active = fediverse_auth_challenge::ActiveModel { | |
| 270 | + id: Set(id), | |
| 271 | + nonce: Set(nonce), | |
| 272 | + handle: Set(target), | |
| 273 | + // Domain stored is the requester's domain (b.tld) — same | |
| 274 | + // semantic as the client-side `initiate` row stores | |
| 275 | + // `b.tld` for the IdP it's calling. Symmetrical. | |
| 276 | + domain: Set(requester.domain.clone()), | |
| 277 | + requested_remote_id: Set(Some(requester.id)), | |
| 278 | + status: Set(ChallengeStatus::Pending.as_str().to_string()), | |
| 279 | + redirect_url: Set(None), | |
| 280 | + created_at: Set(Utc::now()), | |
| 281 | + expires_at: Set(expires_at), | |
| 282 | + consumed_at: Set(None), | |
| 283 | + }; | |
| 284 | + Ok(active.insert(db).await?) | |
| 285 | + } | |
| 286 | + | |
| 287 | + /// Step 1ter (RFC 0025 — back-channel) : called by the SP-side | |
| 288 | + /// `/fediverse/auth/inbox` when the IdP delivers an | |
| 289 | + /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's | |
| 290 | + /// `object` IRI references B's original AuthRequest; the nonce is | |
| 291 | + /// the last URL segment. | |
| 292 | + /// | |
| 293 | + /// Security: the approver's domain (`actor`'s host) must match the | |
| 294 | + /// `domain` recorded on the local challenge when it was initiated. | |
| 295 | + /// Without that check, `evil.tld` could fabricate an `Accept` for a | |
| 296 | + /// nonce it observed and trick B into approving it. | |
| 297 | + /// | |
| 298 | + /// Idempotent: replaying the same decision on a challenge that's | |
| 299 | + /// already left `Pending` returns `Ok(())` without DB write. | |
| 300 | + pub async fn record_inbound_decision( | |
| 301 | + db: &DatabaseConnection, | |
| 302 | + body: &serde_json::Value, | |
| 303 | + ) -> Result<(), AppError> { | |
| 304 | + let activity_type = body | |
| 305 | + .get("type") | |
| 306 | + .and_then(|v| v.as_str()) | |
| 307 | + .ok_or_else(|| AppError::Validation("activity missing `type`".into()))? | |
| 308 | + .to_string(); | |
| 309 | + | |
| 310 | + let approver_actor_url = body | |
| 311 | + .get("actor") | |
| 312 | + .and_then(|v| v.as_str()) | |
| 313 | + .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? | |
| 314 | + .to_string(); | |
| 315 | + | |
| 316 | + let nonce = extract_nonce_from_decision_object(body)?; | |
| 317 | + | |
| 318 | + use crate::models::remote_identity; | |
| 319 | + use sea_orm::{ColumnTrait, QueryFilter}; | |
| 320 | + | |
| 321 | + let challenge = fediverse_auth_challenge::Entity::find() | |
| 322 | + .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) | |
| 323 | + .one(db) | |
| 324 | + .await? | |
| 325 | + .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; | |
| 326 | + | |
| 327 | + // Idempotence on terminal states. | |
| 328 | + if challenge.status != ChallengeStatus::Pending.as_str() { | |
| 329 | + return Ok(()); | |
| 330 | + } | |
| 331 | + if challenge.expires_at < Utc::now() { | |
| 332 | + return Err(AppError::Validation(format!( | |
| 333 | + "challenge {} expired before decision arrived", | |
| 334 | + challenge.id | |
| 335 | + ))); | |
| 336 | + } | |
| 337 | + | |
| 338 | + // Security: the approver must live on the IdP that this | |
| 339 | + // challenge originally targeted. | |
| 340 | + let approver_domain = url::Url::parse(&approver_actor_url) | |
| 341 | + .ok() | |
| 342 | + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase())) | |
| 343 | + .ok_or_else(|| { | |
| 344 | + AppError::Validation(format!( | |
| 345 | + "approver actor URL has no host: {approver_actor_url}" | |
| 346 | + )) | |
| 347 | + })?; | |
| 348 | + if approver_domain != challenge.domain.to_ascii_lowercase() { | |
| 349 | + return Err(AppError::Validation(format!( | |
| 350 | + "approver domain `{approver_domain}` does not match challenge IdP `{}`", | |
| 351 | + challenge.domain | |
| 352 | + ))); | |
| 353 | + } | |
| 354 | + | |
| 355 | + // Resolve the approver in `remote_identities`. F2's verifier | |
| 356 | + // populated this row on signature verification; if it's still | |
| 357 | + // absent, surface the integrity issue rather than approve blindly. | |
| 358 | + let approver = remote_identity::Entity::find() | |
| 359 | + .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) | |
| 360 | + .one(db) | |
| 361 | + .await? | |
| 362 | + .ok_or_else(|| { | |
| 363 | + AppError::Validation(format!( | |
| 364 | + "approver {approver_actor_url} not in remote_identities cache; \ | |
| 365 | + signature verifier should have populated this row", | |
| 366 | + )) | |
| 367 | + })?; | |
| 368 | + | |
| 369 | + let new_status = match activity_type.as_str() { | |
| 370 | + "Accept" => ChallengeStatus::Approved, | |
| 371 | + "Reject" => ChallengeStatus::Rejected, | |
| 372 | + other => { | |
| 373 | + return Err(AppError::Validation(format!( | |
| 374 | + "record_inbound_decision: unsupported type `{other}` (expected Accept|Reject)" | |
| 375 | + ))) | |
| 376 | + } | |
| 377 | + }; | |
| 378 | + | |
| 379 | + let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); | |
| 380 | + active.status = Set(new_status.as_str().to_string()); | |
| 381 | + active.requested_remote_id = Set(Some(approver.id)); | |
| 382 | + active.update(db).await?; | |
| 383 | + Ok(()) | |
| 384 | + } | |
| 385 | + | |
| 386 | + /// Step 3 — caller polls. Promotes pending-but-expired rows to | |
| 387 | + /// `Expired` lazily so the caller observes a stable terminal state. | |
| 388 | + pub async fn status( | |
| 389 | + db: &DatabaseConnection, | |
| 390 | + challenge_id: Uuid, | |
| 391 | + ) -> Result<fediverse_auth_challenge::Model, AppError> { | |
| 392 | + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 393 | + .one(db) | |
| 394 | + .await? | |
| 395 | + .ok_or_else(|| { | |
| 396 | + AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 397 | + })?; | |
| 398 | + | |
| 399 | + if model.status == ChallengeStatus::Pending.as_str() | |
| 400 | + && model.expires_at < Utc::now() | |
| 401 | + { | |
| 402 | + let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 403 | + active.status = Set(ChallengeStatus::Expired.as_str().to_string()); | |
| 404 | + return Ok(active.update(db).await?); | |
| 405 | + } | |
| 406 | + Ok(model) | |
| 407 | + } | |
| 408 | + | |
| 409 | + /// Step 4 — atomically mint a remote JWT from an `Approved` | |
| 410 | + /// challenge. | |
| 411 | + pub async fn consume( | |
| 412 | + db: &DatabaseConnection, | |
| 413 | + config: &AuthConfig, | |
| 414 | + challenge_id: Uuid, | |
| 415 | + ) -> Result<ConsumedSession, AppError> { | |
| 416 | + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id) | |
| 417 | + .one(db) | |
| 418 | + .await? | |
| 419 | + .ok_or_else(|| { | |
| 420 | + AppError::NotFound(format!("Auth challenge {challenge_id} not found")) | |
| 421 | + })?; | |
| 422 | + | |
| 423 | + match ChallengeStatus::parse(&model.status) { | |
| 424 | + ChallengeStatus::Approved => {} | |
| 425 | + ChallengeStatus::Consumed => { | |
| 426 | + return Err(AppError::Validation( | |
| 427 | + "Challenge already consumed".to_string(), | |
| 428 | + )); | |
| 429 | + } | |
| 430 | + other => { | |
| 431 | + return Err(AppError::Validation(format!( | |
| 432 | + "Cannot consume challenge in state {}", | |
| 433 | + other.as_str() | |
| 434 | + ))); | |
| 435 | + } | |
| 436 | + } | |
| 437 | + | |
| 438 | + let remote_identity_id = model.requested_remote_id.ok_or_else(|| { | |
| 439 | + AppError::Internal( | |
| 440 | + "Approved challenge missing remote_identity_id (data integrity bug)" | |
| 441 | + .to_string(), | |
| 442 | + ) | |
| 443 | + })?; | |
| 444 | + let handle = model.handle.clone(); | |
| 445 | + | |
| 446 | + let jwt = JwtService::generate_remote_token(config, remote_identity_id, &handle)?; | |
| 447 | + | |
| 448 | + let mut active: fediverse_auth_challenge::ActiveModel = model.into(); | |
| 449 | + active.status = Set(ChallengeStatus::Consumed.as_str().to_string()); | |
| 450 | + active.consumed_at = Set(Some(Utc::now())); | |
| 451 | + active.update(db).await?; | |
| 452 | + | |
| 453 | + Ok(ConsumedSession { | |
| 454 | + jwt, | |
| 455 | + remote_identity_id, | |
| 456 | + handle, | |
| 457 | + }) | |
| 458 | + } | |
| 459 | +} | |
| 460 | + | |
| 461 | +/// Build the JSON-LD body of the `AuthRequest` activity (unsigned). | |
| 462 | +/// | |
| 463 | +/// The HTTP signature is attached at delivery time by the | |
| 464 | +/// `OutboundDelivery` implementation supplied to the worker. | |
| 465 | +pub fn build_auth_request_payload( | |
| 466 | + config: &FediverseConfig, | |
| 467 | + challenge: &InitiatedChallenge, | |
| 468 | + target_handle: &str, | |
| 469 | +) -> serde_json::Value { | |
| 470 | + serde_json::json!({ | |
| 471 | + "@context": [ | |
| 472 | + "https://www.w3.org/ns/activitystreams", | |
| 473 | + "https://schema.rustwarden.org/auth-request/v1" | |
| 474 | + ], | |
| 475 | + "type": "AuthRequest", | |
| 476 | + "id": format!("{}auth-requests/{}", config.base_url(), challenge.id), | |
| 477 | + "actor": format!("{}", config.base_url()), | |
| 478 | + "target": target_handle, | |
| 479 | + "nonce": challenge.nonce, | |
| 480 | + "expiresAt": challenge.expires_at.to_rfc3339(), | |
| 481 | + }) | |
| 482 | +} | |
| 483 | + | |
| 484 | +/// Build the JSON-LD body of an `Accept(AuthRequest)` or | |
| 485 | +/// `Reject(AuthRequest)` activity (RFC 0025). | |
| 486 | +/// | |
| 487 | +/// The IRI-only form of `object` would require the IdP to know B's | |
| 488 | +/// AuthRequest URL — which we don't store. We use the inline-object | |
| 489 | +/// form `{ type: "AuthRequest", nonce }` instead; the receiving side | |
| 490 | +/// extracts the nonce via [`extract_nonce_from_decision_object`]. | |
| 491 | +/// | |
| 492 | +/// `idp_base_url` is the canonical base URL of A (the IdP, e.g. | |
| 493 | +/// `http://alpha.local/`). `approver_actor_url` is the AP id of the | |
| 494 | +/// local user that clicked Approve / Reject. `requester_inbox_url` is | |
| 495 | +/// the inbox of B's user that initiated the AuthRequest (delivery | |
| 496 | +/// target). `approve=true` emits Accept, `false` emits Reject. | |
| 497 | +pub fn build_auth_decision_payload( | |
| 498 | + idp_base_url: &url::Url, | |
| 499 | + approver_actor_url: &str, | |
| 500 | + requester_inbox_url: &str, | |
| 501 | + nonce: &str, | |
| 502 | + approve: bool, | |
| 503 | +) -> serde_json::Value { | |
| 504 | + let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 505 | + let activity_id = format!("{}auth-decisions/{}", idp_base_url, Uuid::new_v4()); | |
| 506 | + serde_json::json!({ | |
| 507 | + "@context": [ | |
| 508 | + "https://www.w3.org/ns/activitystreams", | |
| 509 | + { | |
| 510 | + "AuthRequest": { | |
| 511 | + "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" | |
| 512 | + } | |
| 513 | + } | |
| 514 | + ], | |
| 515 | + "type": activity_type, | |
| 516 | + "id": activity_id, | |
| 517 | + "actor": approver_actor_url, | |
| 518 | + "object": { | |
| 519 | + "type": "AuthRequest", | |
| 520 | + "nonce": nonce, | |
| 521 | + }, | |
| 522 | + "to": [ requester_inbox_url ], | |
| 523 | + }) | |
| 524 | +} | |
| 525 | + | |
| 526 | +/// Extract the AuthRequest nonce from the `object` field of an inbound | |
| 527 | +/// `Accept` or `Reject` activity. Supports two payload shapes: | |
| 528 | +/// - **IRI form** (canonical) : `"object": "http://b.tld/auth-requests/<nonce>"` | |
| 529 | +/// — the nonce is the last URL path segment. | |
| 530 | +/// - **Inline-object form** (defensive fallback) : `"object": { "nonce": "<v>" }`. | |
| 531 | +fn extract_nonce_from_decision_object(body: &serde_json::Value) -> Result<String, AppError> { | |
| 532 | + let object = body | |
| 533 | + .get("object") | |
| 534 | + .ok_or_else(|| AppError::Validation("Accept/Reject missing `object`".into()))?; | |
| 535 | + | |
| 536 | + if let Some(iri) = object.as_str() { | |
| 537 | + let nonce = iri.rsplit('/').next().unwrap_or("").trim(); | |
| 538 | + if nonce.is_empty() { | |
| 539 | + return Err(AppError::Validation(format!( | |
| 540 | + "cannot extract nonce from object IRI `{iri}`" | |
| 541 | + ))); | |
| 542 | + } | |
| 543 | + return Ok(nonce.to_string()); | |
| 544 | + } | |
| 545 | + if let Some(inline) = object.as_object() | |
| 546 | + && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) | |
| 547 | + && !nonce.is_empty() | |
| 548 | + { | |
| 549 | + return Ok(nonce.to_string()); | |
| 550 | + } | |
| 551 | + Err(AppError::Validation( | |
| 552 | + "cannot extract nonce from Accept/Reject `object`".into(), | |
| 553 | + )) | |
| 554 | +} | |
| 555 | + | |
| 556 | +fn handle_domain(handle: &str) -> Result<String, AppError> { | |
| 557 | + let trimmed = handle.trim().trim_start_matches('@'); | |
| 558 | + let (_, domain) = trimmed.split_once('@').ok_or_else(|| { | |
| 559 | + AppError::Validation(format!( | |
| 560 | + "Invalid handle '{handle}': expected user@domain", | |
| 561 | + )) | |
| 562 | + })?; | |
| 563 | + if domain.is_empty() { | |
| 564 | + return Err(AppError::Validation(format!( | |
| 565 | + "Invalid handle '{handle}': empty domain", | |
| 566 | + ))); | |
| 567 | + } | |
| 568 | + Ok(domain.to_ascii_lowercase()) | |
| 569 | +} | |
| 570 | + | |
| 571 | +fn generate_nonce() -> String { | |
| 572 | + let mut buf = [0u8; 32]; | |
| 573 | + rand::rng().fill_bytes(&mut buf); | |
| 574 | + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf) | |
| 575 | +} | |
| 576 | + | |
| 577 | +#[cfg(test)] | |
| 578 | +mod tests { | |
| 579 | + use super::*; | |
| 580 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 581 | + | |
| 582 | + fn fresh_pending(id: Uuid, handle: &str) -> fediverse_auth_challenge::Model { | |
| 583 | + let now = Utc::now(); | |
| 584 | + fediverse_auth_challenge::Model { | |
| 585 | + id, | |
| 586 | + nonce: "AAAA".to_string(), | |
| 587 | + handle: handle.to_string(), | |
| 588 | + domain: handle.split('@').next_back().unwrap_or("").to_string(), | |
| 589 | + requested_remote_id: None, | |
| 590 | + status: ChallengeStatus::Pending.as_str().to_string(), | |
| 591 | + redirect_url: None, | |
| 592 | + created_at: now, | |
| 593 | + expires_at: now + ChronoDuration::minutes(5), | |
| 594 | + consumed_at: None, | |
| 595 | + } | |
| 596 | + } | |
| 597 | + | |
| 598 | + fn test_auth_config() -> AuthConfig { | |
| 599 | + AuthConfig { | |
| 600 | + jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 601 | + .to_string(), | |
| 602 | + jwt_expiration_minutes: 15, | |
| 603 | + jwt_issuer: "rustwarden-test".to_string(), | |
| 604 | + refresh_token_expiration_days: 7, | |
| 605 | + remember_me_expiration_days: 30, | |
| 606 | + session_timeout_minutes: 30, | |
| 607 | + session_backend: rustwarden_core::config::auth::SessionBackend::Memory, | |
| 608 | + redis_url: None, | |
| 609 | + rate_limit_login_per_minute: 5, | |
| 610 | + rate_limit_refresh_per_minute: 10, | |
| 611 | + rate_limit_general_per_minute: 100, | |
| 612 | + cookie_secure: false, | |
| 613 | + cookie_same_site: "Strict".to_string(), | |
| 614 | + } | |
| 615 | + } | |
| 616 | + | |
| 617 | + fn test_fediverse_config() -> FediverseConfig { | |
| 618 | + FediverseConfig::for_tests("test.local") | |
| 619 | + } | |
| 620 | + | |
| 621 | + // ─── Pure helpers ─────────────────────────────────────────────── | |
| 622 | + | |
| 623 | + #[test] | |
| 624 | + fn challenge_status_round_trips_through_string() { | |
| 625 | + for s in [ | |
| 626 | + ChallengeStatus::Pending, | |
| 627 | + ChallengeStatus::Approved, | |
| 628 | + ChallengeStatus::Rejected, | |
| 629 | + ChallengeStatus::Expired, | |
| 630 | + ChallengeStatus::Consumed, | |
| 631 | + ] { | |
| 632 | + assert_eq!(ChallengeStatus::parse(s.as_str()), s); | |
| 633 | + } | |
| 634 | + assert_eq!(ChallengeStatus::parse("garbage"), ChallengeStatus::Pending); | |
| 635 | + } | |
| 636 | + | |
| 637 | + #[test] | |
| 638 | + fn build_auth_request_payload_includes_nonce_and_target() { | |
| 639 | + let config = test_fediverse_config(); | |
| 640 | + let challenge = InitiatedChallenge { | |
| 641 | + id: Uuid::new_v4(), | |
| 642 | + nonce: "test-nonce-xxx".into(), | |
| 643 | + expires_at: Utc::now() + ChronoDuration::minutes(5), | |
| 644 | + }; | |
| 645 | + let payload = build_auth_request_payload(&config, &challenge, "alice@a.tld"); | |
| 646 | + assert_eq!(payload["nonce"], serde_json::json!("test-nonce-xxx")); | |
| 647 | + assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); | |
| 648 | + assert_eq!(payload["type"], serde_json::json!("AuthRequest")); | |
| 649 | + } | |
| 650 | + | |
| 651 | + #[test] | |
| 652 | + fn build_auth_request_payload_uses_fediverse_base_url() { | |
| 653 | + let config = test_fediverse_config(); | |
| 654 | + let challenge = InitiatedChallenge { | |
| 655 | + id: Uuid::new_v4(), | |
| 656 | + nonce: "n".into(), | |
| 657 | + expires_at: Utc::now(), | |
| 658 | + }; | |
| 659 | + let payload = build_auth_request_payload(&config, &challenge, "bob@b.tld"); | |
| 660 | + let id = payload["id"].as_str().unwrap(); | |
| 661 | + assert!( | |
| 662 | + id.contains(config.base_url().as_str()), | |
| 663 | + "id must embed base_url, got {id}", | |
| 664 | + ); | |
| 665 | + } | |
| 666 | + | |
| 667 | + // ─── DB-mocked ────────────────────────────────────────────────── | |
| 668 | + | |
| 669 | + #[tokio::test] | |
| 670 | + async fn initiate_persists_pending_challenge_with_fresh_nonce() { | |
| 671 | + let id = Uuid::new_v4(); | |
| 672 | + let row = fresh_pending(id, "alice@a.tld"); | |
| 673 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 674 | + .append_query_results([vec![row.clone()]]) | |
| 675 | + .into_connection(); | |
| 676 | + let initiated = FediverseAuthService::initiate( | |
| 677 | + &db, | |
| 678 | + InitiateRequest { | |
| 679 | + handle: "alice@a.tld".into(), | |
| 680 | + redirect_url: None, | |
| 681 | + }, | |
| 682 | + Duration::from_secs(300), | |
| 683 | + ) | |
| 684 | + .await | |
| 685 | + .expect("initiate ok"); | |
| 686 | + assert!(!initiated.nonce.is_empty()); | |
| 687 | + } | |
| 688 | + | |
| 689 | + #[test] | |
| 690 | + fn generate_nonce_is_unique_and_url_safe() { | |
| 691 | + // Direct test of the helper: the mock returns whatever we | |
| 692 | + // hand it back, so guarantee the **service-side** randomness | |
| 693 | + // by exercising the generator directly. | |
| 694 | + let a = generate_nonce(); | |
| 695 | + let b = generate_nonce(); | |
| 696 | + assert_ne!(a, b); | |
| 697 | + assert!( | |
| 698 | + a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'), | |
| 699 | + "nonce must be URL-safe base64, got {a}", | |
| 700 | + ); | |
| 701 | + assert!(a.len() >= 32, "nonce length suspicious: {}", a.len()); | |
| 702 | + } | |
| 703 | + | |
| 704 | + #[tokio::test] | |
| 705 | + async fn record_decision_approve_marks_approved_and_records_remote_id() { | |
| 706 | + let id = Uuid::new_v4(); | |
| 707 | + let remote_id = Uuid::new_v4(); | |
| 708 | + let row = fresh_pending(id, "alice@a.tld"); | |
| 709 | + let mut after = row.clone(); | |
| 710 | + after.status = ChallengeStatus::Approved.as_str().to_string(); | |
| 711 | + after.requested_remote_id = Some(remote_id); | |
| 712 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 713 | + .append_query_results([vec![row]]) | |
| 714 | + .append_query_results([vec![after]]) | |
| 715 | + .append_exec_results([MockExecResult { | |
| 716 | + last_insert_id: 0, | |
| 717 | + rows_affected: 1, | |
| 718 | + }]) | |
| 719 | + .into_connection(); | |
| 720 | + | |
| 721 | + let updated = FediverseAuthService::record_decision( | |
| 722 | + &db, | |
| 723 | + id, | |
| 724 | + ChallengeDecision::Approve { | |
| 725 | + remote_identity_id: remote_id, | |
| 726 | + }, | |
| 727 | + ) | |
| 728 | + .await | |
| 729 | + .unwrap(); | |
| 730 | + assert_eq!(updated.status, "approved"); | |
| 731 | + assert_eq!(updated.requested_remote_id, Some(remote_id)); | |
| 732 | + } | |
| 733 | + | |
| 734 | + #[tokio::test] | |
| 735 | + async fn record_decision_reject_marks_rejected() { | |
| 736 | + let id = Uuid::new_v4(); | |
| 737 | + let row = fresh_pending(id, "alice@a.tld"); | |
| 738 | + let mut after = row.clone(); | |
| 739 | + after.status = ChallengeStatus::Rejected.as_str().to_string(); | |
| 740 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 741 | + .append_query_results([vec![row]]) | |
| 742 | + .append_query_results([vec![after]]) | |
| 743 | + .append_exec_results([MockExecResult { | |
| 744 | + last_insert_id: 0, | |
| 745 | + rows_affected: 1, | |
| 746 | + }]) | |
| 747 | + .into_connection(); | |
| 748 | + | |
| 749 | + let updated = | |
| 750 | + FediverseAuthService::record_decision(&db, id, ChallengeDecision::Reject) | |
| 751 | + .await | |
| 752 | + .unwrap(); | |
| 753 | + assert_eq!(updated.status, "rejected"); | |
| 754 | + } | |
| 755 | + | |
| 756 | + #[tokio::test] | |
| 757 | + async fn status_marks_expired_when_past_expires_at() { | |
| 758 | + let id = Uuid::new_v4(); | |
| 759 | + let mut row = fresh_pending(id, "alice@a.tld"); | |
| 760 | + row.expires_at = Utc::now() - ChronoDuration::minutes(1); | |
| 761 | + let mut after = row.clone(); | |
| 762 | + after.status = ChallengeStatus::Expired.as_str().to_string(); | |
| 763 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 764 | + .append_query_results([vec![row]]) | |
| 765 | + .append_query_results([vec![after]]) | |
| 766 | + .append_exec_results([MockExecResult { | |
| 767 | + last_insert_id: 0, | |
| 768 | + rows_affected: 1, | |
| 769 | + }]) | |
| 770 | + .into_connection(); | |
| 771 | + | |
| 772 | + let returned = FediverseAuthService::status(&db, id).await.unwrap(); | |
| 773 | + assert_eq!(returned.status, "expired"); | |
| 774 | + } | |
| 775 | + | |
| 776 | + #[tokio::test] | |
| 777 | + async fn status_returns_pending_when_not_decided_yet() { | |
| 778 | + let id = Uuid::new_v4(); | |
| 779 | + let row = fresh_pending(id, "alice@a.tld"); | |
| 780 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 781 | + .append_query_results([vec![row]]) | |
| 782 | + .into_connection(); | |
| 783 | + let returned = FediverseAuthService::status(&db, id).await.unwrap(); | |
| 784 | + assert_eq!(returned.status, "pending"); | |
| 785 | + } | |
| 786 | + | |
| 787 | + #[tokio::test] | |
| 788 | + async fn consume_rejects_pending_challenge() { | |
| 789 | + let id = Uuid::new_v4(); | |
| 790 | + let row = fresh_pending(id, "alice@a.tld"); | |
| 791 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 792 | + .append_query_results([vec![row]]) | |
| 793 | + .into_connection(); | |
| 794 | + let err = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 795 | + .await | |
| 796 | + .expect_err("must refuse pending"); | |
| 797 | + assert!(matches!(err, AppError::Validation(_))); | |
| 798 | + } | |
| 799 | + | |
| 800 | + #[tokio::test] | |
| 801 | + async fn consume_rejects_already_consumed_challenge() { | |
| 802 | + let id = Uuid::new_v4(); | |
| 803 | + let mut row = fresh_pending(id, "alice@a.tld"); | |
| 804 | + row.status = ChallengeStatus::Consumed.as_str().to_string(); | |
| 805 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 806 | + .append_query_results([vec![row]]) | |
| 807 | + .into_connection(); | |
| 808 | + let err = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 809 | + .await | |
| 810 | + .expect_err("must refuse consumed"); | |
| 811 | + assert!(matches!(err, AppError::Validation(_))); | |
| 812 | + } | |
| 813 | + | |
| 814 | + #[tokio::test] | |
| 815 | + async fn consume_emits_remote_jwt_for_approved_challenge() { | |
| 816 | + let id = Uuid::new_v4(); | |
| 817 | + let remote_id = Uuid::new_v4(); | |
| 818 | + let mut row = fresh_pending(id, "alice@a.tld"); | |
| 819 | + row.status = ChallengeStatus::Approved.as_str().to_string(); | |
| 820 | + row.requested_remote_id = Some(remote_id); | |
| 821 | + let mut after = row.clone(); | |
| 822 | + after.status = ChallengeStatus::Consumed.as_str().to_string(); | |
| 823 | + after.consumed_at = Some(Utc::now()); | |
| 824 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 825 | + .append_query_results([vec![row]]) | |
| 826 | + .append_query_results([vec![after]]) | |
| 827 | + .append_exec_results([MockExecResult { | |
| 828 | + last_insert_id: 0, | |
| 829 | + rows_affected: 1, | |
| 830 | + }]) | |
| 831 | + .into_connection(); | |
| 832 | + let session = FediverseAuthService::consume(&db, &test_auth_config(), id) | |
| 833 | + .await | |
| 834 | + .unwrap(); | |
| 835 | + assert_eq!(session.remote_identity_id, remote_id); | |
| 836 | + assert_eq!(session.handle, "alice@a.tld"); | |
| 837 | + assert!(!session.jwt.is_empty()); | |
| 838 | + } | |
| 839 | + | |
| 840 | + // ─── Phase 2 (RFC 0006) — inbox enrichment ────────────────────── | |
| 841 | + | |
| 842 | + fn fresh_remote_identity(actor_url: &str, domain: &str) -> crate::models::remote_identity::Model { | |
| 843 | + let now = Utc::now(); | |
| 844 | + crate::models::remote_identity::Model { | |
| 845 | + id: Uuid::new_v4(), | |
| 846 | + ap_id: actor_url.to_string(), | |
| 847 | + username: "requester".to_string(), | |
| 848 | + domain: domain.to_string(), | |
| 849 | + display_name: None, | |
| 850 | + summary_html: None, | |
| 851 | + inbox_url: format!("{actor_url}/inbox"), | |
| 852 | + shared_inbox_url: None, | |
| 853 | + public_key_pem: "-----BEGIN PUBLIC KEY-----\nfoo\n-----END PUBLIC KEY-----\n" | |
| 854 | + .to_string(), | |
| 855 | + icon_url: None, | |
| 856 | + actor_type: "Person".to_string(), | |
| 857 | + raw_json: None, | |
| 858 | + last_fetched_at: now, | |
| 859 | + disabled_at: None, | |
| 860 | + created_at: now, | |
| 861 | + updated_at: now, | |
| 862 | + } | |
| 863 | + } | |
| 864 | + | |
| 865 | + #[tokio::test] | |
| 866 | + async fn persist_inbound_request_inserts_pending_with_remote_id() { | |
| 867 | + let requester = fresh_remote_identity("https://b.tld/", "b.tld"); | |
| 868 | + let requester_id = requester.id; | |
| 869 | + let inserted = fediverse_auth_challenge::Model { | |
| 870 | + id: Uuid::new_v4(), | |
| 871 | + nonce: "n".to_string(), | |
| 872 | + handle: "alice@a.tld".to_string(), | |
| 873 | + domain: "b.tld".to_string(), | |
| 874 | + requested_remote_id: Some(requester_id), | |
| 875 | + status: ChallengeStatus::Pending.as_str().to_string(), | |
| 876 | + redirect_url: None, | |
| 877 | + created_at: Utc::now(), | |
| 878 | + expires_at: Utc::now() + ChronoDuration::minutes(5), | |
| 879 | + consumed_at: None, | |
| 880 | + }; | |
| 881 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 882 | + .append_query_results([vec![requester]]) | |
| 883 | + .append_query_results([vec![inserted]]) | |
| 884 | + .append_exec_results([MockExecResult { | |
| 885 | + last_insert_id: 0, | |
| 886 | + rows_affected: 1, | |
| 887 | + }]) | |
| 888 | + .into_connection(); | |
| 889 | + | |
| 890 | + let body = serde_json::json!({ | |
| 891 | + "type": "AuthRequest", | |
| 892 | + "actor": "https://b.tld/", | |
| 893 | + "target": "@alice@a.tld", | |
| 894 | + "nonce": "n", | |
| 895 | + "expiresAt": (Utc::now() + ChronoDuration::minutes(5)).to_rfc3339(), | |
| 896 | + }); | |
| 897 | + | |
| 898 | + let row = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 899 | + .await | |
| 900 | + .expect("persist_inbound_request ok"); | |
| 901 | + assert_eq!(row.handle, "alice@a.tld"); | |
| 902 | + assert_eq!(row.domain, "b.tld"); | |
| 903 | + assert_eq!(row.requested_remote_id, Some(requester_id)); | |
| 904 | + } | |
| 905 | + | |
| 906 | + #[tokio::test] | |
| 907 | + async fn persist_inbound_request_rejects_wrong_idp_domain() { | |
| 908 | + let db: DatabaseConnection = | |
| 909 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 910 | + let body = serde_json::json!({ | |
| 911 | + "actor": "https://b.tld/", | |
| 912 | + "target": "@alice@evil.tld", | |
| 913 | + "nonce": "n", | |
| 914 | + }); | |
| 915 | + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 916 | + .await | |
| 917 | + .expect_err("must reject mismatched IdP domain"); | |
| 918 | + assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 919 | + } | |
| 920 | + | |
| 921 | + #[tokio::test] | |
| 922 | + async fn persist_inbound_request_rejects_missing_actor() { | |
| 923 | + let db: DatabaseConnection = | |
| 924 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 925 | + let body = serde_json::json!({ | |
| 926 | + "target": "@alice@a.tld", | |
| 927 | + "nonce": "n", | |
| 928 | + }); | |
| 929 | + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 930 | + .await | |
| 931 | + .expect_err("must reject missing actor"); | |
| 932 | + assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 933 | + } | |
| 934 | + | |
| 935 | + #[tokio::test] | |
| 936 | + async fn persist_inbound_request_rejects_unknown_requester() { | |
| 937 | + // No remote_identities row matches → service surfaces a clear | |
| 938 | + // Validation error rather than inserting a stray challenge. | |
| 939 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 940 | + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()]) | |
| 941 | + .into_connection(); | |
| 942 | + let body = serde_json::json!({ | |
| 943 | + "actor": "https://unknown.tld/", | |
| 944 | + "target": "@alice@a.tld", | |
| 945 | + "nonce": "n", | |
| 946 | + }); | |
| 947 | + let err = FediverseAuthService::persist_inbound_request(&db, &body, "a.tld") | |
| 948 | + .await | |
| 949 | + .expect_err("must reject unknown requester"); | |
| 950 | + assert!(matches!(err, AppError::Validation(_)), "got {err:?}"); | |
| 951 | + } | |
| 952 | +} |
A
crates/rustwarden-fediverse/src/services/fediverse_invite_service.rs
+540
-0
@@ -0,0 +1,540 @@
| 1 | +//! Cross-instance team-membership invitations (RFC 0009). | |
| 2 | +//! | |
| 3 | +//! State machine `Pending → Accepted | Rejected | Revoked | Expired`. | |
| 4 | +//! On `Accept`, the service resolves the remote identity and inserts | |
| 5 | +//! a `team_members` row with `remote_identity_id` populated. The AP | |
| 6 | +//! activities themselves are produced by the `build_*_payload` helpers | |
| 7 | +//! in [`crate::services::fediverse_invite_payloads`] and dispatched by | |
| 8 | +//! the consumer through [`super::fediverse_outbound_service::FediverseOutboundService`]. | |
| 9 | +//! | |
| 10 | +//! Gated by `experimental-fediverse`. | |
| 11 | + | |
| 12 | +use chrono::{Duration as ChronoDuration, Utc}; | |
| 13 | +use sea_orm::{ | |
| 14 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, | |
| 15 | +}; | |
| 16 | +use std::time::Duration; | |
| 17 | +use uuid::Uuid; | |
| 18 | + | |
| 19 | +use crate::config::FediverseConfig; | |
| 20 | +use rustwarden_core::error::AppError; | |
| 21 | +use crate::models::fediverse_pending_invite; | |
| 22 | +use rustwarden_core::models::{team, team_member}; | |
| 23 | +use rustwarden_core::services::team_service::TeamRole; | |
| 24 | + | |
| 25 | +/// Lifecycle of a [`fediverse_pending_invite::Model`] row. | |
| 26 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 27 | +pub enum InviteStatus { | |
| 28 | + /// Awaiting IdP decision. | |
| 29 | + Pending, | |
| 30 | + /// IdP user clicked Accept; the membership row exists. | |
| 31 | + Accepted, | |
| 32 | + /// IdP user clicked Reject. | |
| 33 | + Rejected, | |
| 34 | + /// Inviter cancelled the invitation. | |
| 35 | + Revoked, | |
| 36 | + /// `expires_at` reached without decision. | |
| 37 | + Expired, | |
| 38 | +} | |
| 39 | + | |
| 40 | +impl InviteStatus { | |
| 41 | + /// Wire form stored in the `status` column. | |
| 42 | + pub fn as_str(self) -> &'static str { | |
| 43 | + match self { | |
| 44 | + Self::Pending => "pending", | |
| 45 | + Self::Accepted => "accepted", | |
| 46 | + Self::Rejected => "rejected", | |
| 47 | + Self::Revoked => "revoked", | |
| 48 | + Self::Expired => "expired", | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Best-effort parse — unknown values fall back to `Pending`. | |
| 53 | + pub fn parse(raw: &str) -> Self { | |
| 54 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 55 | + "accepted" => Self::Accepted, | |
| 56 | + "rejected" => Self::Rejected, | |
| 57 | + "revoked" => Self::Revoked, | |
| 58 | + "expired" => Self::Expired, | |
| 59 | + _ => Self::Pending, | |
| 60 | + } | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +/// Stateless service exposing the invite state machine. | |
| 65 | +pub struct FediverseInviteService; | |
| 66 | + | |
| 67 | +impl FediverseInviteService { | |
| 68 | + /// Persist a `pending` invitation. The caller is expected to | |
| 69 | + /// enqueue the AP `Invite{TeamMembership}` activity to the | |
| 70 | + /// remote inbox via `FediverseOutboundService::enqueue` using | |
| 71 | + /// [`build_invite_payload`]. | |
| 72 | + pub async fn invite_remote( | |
| 73 | + db: &DatabaseConnection, | |
| 74 | + team_id: Uuid, | |
| 75 | + invited_handle: &str, | |
| 76 | + role: TeamRole, | |
| 77 | + invited_by_user_id: Uuid, | |
| 78 | + ttl: Duration, | |
| 79 | + ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 80 | + let now = Utc::now(); | |
| 81 | + let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| { | |
| 82 | + AppError::Internal(format!("ttl out of range for chrono::Duration: {e}")) | |
| 83 | + })?; | |
| 84 | + let id = Uuid::new_v4(); | |
| 85 | + let active = fediverse_pending_invite::ActiveModel { | |
| 86 | + id: Set(id), | |
| 87 | + activity_id: Set(format!("https://invite/{id}")), // tweak in handler with FediverseConfig if needed | |
| 88 | + team_id: Set(team_id), | |
| 89 | + invited_handle: Set(invited_handle.to_string()), | |
| 90 | + invited_remote_identity_id: Set(None), | |
| 91 | + role: Set(role.as_str().to_string()), | |
| 92 | + invited_by_user_id: Set(invited_by_user_id), | |
| 93 | + status: Set(InviteStatus::Pending.as_str().to_string()), | |
| 94 | + created_at: Set(now), | |
| 95 | + expires_at: Set(expires_at), | |
| 96 | + decided_at: Set(None), | |
| 97 | + }; | |
| 98 | + Ok(active.insert(db).await?) | |
| 99 | + } | |
| 100 | + | |
| 101 | + /// IdP-side accept: marks the invite `Accepted`, persists the | |
| 102 | + /// resolved `remote_identity_id`, and inserts the `team_members` | |
| 103 | + /// row with the remote sibling FK set. | |
| 104 | + pub async fn accept( | |
| 105 | + db: &DatabaseConnection, | |
| 106 | + invite_id: Uuid, | |
| 107 | + remote_identity_id: Uuid, | |
| 108 | + ) -> Result<team_member::Model, AppError> { | |
| 109 | + let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 110 | + .one(db) | |
| 111 | + .await? | |
| 112 | + .ok_or_else(|| { | |
| 113 | + AppError::NotFound(format!("Invite {invite_id} not found")) | |
| 114 | + })?; | |
| 115 | + | |
| 116 | + if invite.status != InviteStatus::Pending.as_str() { | |
| 117 | + return Err(AppError::Validation(format!( | |
| 118 | + "Cannot accept: invite is in state {}", | |
| 119 | + invite.status | |
| 120 | + ))); | |
| 121 | + } | |
| 122 | + | |
| 123 | + let team_id = invite.team_id; | |
| 124 | + let role = invite.role.clone(); | |
| 125 | + | |
| 126 | + let mut active: fediverse_pending_invite::ActiveModel = invite.into(); | |
| 127 | + active.status = Set(InviteStatus::Accepted.as_str().to_string()); | |
| 128 | + active.invited_remote_identity_id = Set(Some(remote_identity_id)); | |
| 129 | + active.decided_at = Set(Some(Utc::now())); | |
| 130 | + active.update(db).await?; | |
| 131 | + | |
| 132 | + let member = team_member::ActiveModel { | |
| 133 | + id: Set(Uuid::new_v4()), | |
| 134 | + team_id: Set(team_id), | |
| 135 | + // RFC 0018: PK relaxed; remote rows leave `user_id` NULL. | |
| 136 | + // The CHECK XOR guarantees exactly one principal column is | |
| 137 | + // populated; the unique partial index on | |
| 138 | + // (team_id, remote_identity_id) prevents duplicates. | |
| 139 | + user_id: Set(None), | |
| 140 | + role: Set(role), | |
| 141 | + joined_at: Set(Utc::now()), | |
| 142 | + remote_identity_id: Set(Some(remote_identity_id)), | |
| 143 | + }; | |
| 144 | + Ok(member.insert(db).await?) | |
| 145 | + } | |
| 146 | + | |
| 147 | + /// IdP-side reject. | |
| 148 | + pub async fn reject( | |
| 149 | + db: &DatabaseConnection, | |
| 150 | + invite_id: Uuid, | |
| 151 | + ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 152 | + Self::transition_terminal(db, invite_id, InviteStatus::Rejected, &[InviteStatus::Pending]).await | |
| 153 | + } | |
| 154 | + | |
| 155 | + /// Inviter-side revoke. Allowed both before and after acceptance | |
| 156 | + /// — when revoking an accepted invite, the membership row stays | |
| 157 | + /// in place and is removed by the lifecycle signal handler in | |
| 158 | + /// RFC 0010 (`Revoke{Membership}`). | |
| 159 | + pub async fn revoke( | |
| 160 | + db: &DatabaseConnection, | |
| 161 | + invite_id: Uuid, | |
| 162 | + ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 163 | + Self::transition_terminal( | |
| 164 | + db, | |
| 165 | + invite_id, | |
| 166 | + InviteStatus::Revoked, | |
| 167 | + &[InviteStatus::Pending, InviteStatus::Accepted], | |
| 168 | + ) | |
| 169 | + .await | |
| 170 | + } | |
| 171 | + | |
| 172 | + /// List currently-pending invites for a team. | |
| 173 | + pub async fn list_pending_for_team( | |
| 174 | + db: &DatabaseConnection, | |
| 175 | + team_id: Uuid, | |
| 176 | + ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { | |
| 177 | + Ok(fediverse_pending_invite::Entity::find() | |
| 178 | + .filter(fediverse_pending_invite::Column::TeamId.eq(team_id)) | |
| 179 | + .filter( | |
| 180 | + fediverse_pending_invite::Column::Status | |
| 181 | + .eq(InviteStatus::Pending.as_str()), | |
| 182 | + ) | |
| 183 | + .all(db) | |
| 184 | + .await?) | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// List currently-pending invites addressed to a specific handle | |
| 188 | + /// (`@user@domain`). Used by the IdP-side endpoint | |
| 189 | + /// `GET /api/v1/fediverse/invites`. In `debug_local_federation` | |
| 190 | + /// mode both sides share the same DB; in production a real IdP | |
| 191 | + /// keeps a mirror table populated by its own inbox handler. | |
| 192 | + pub async fn list_pending_for_handle( | |
| 193 | + db: &DatabaseConnection, | |
| 194 | + handle: &str, | |
| 195 | + ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> { | |
| 196 | + Ok(fediverse_pending_invite::Entity::find() | |
| 197 | + .filter(fediverse_pending_invite::Column::InvitedHandle.eq(handle)) | |
| 198 | + .filter( | |
| 199 | + fediverse_pending_invite::Column::Status | |
| 200 | + .eq(InviteStatus::Pending.as_str()), | |
| 201 | + ) | |
| 202 | + .all(db) | |
| 203 | + .await?) | |
| 204 | + } | |
| 205 | + | |
| 206 | + async fn transition_terminal( | |
| 207 | + db: &DatabaseConnection, | |
| 208 | + invite_id: Uuid, | |
| 209 | + target: InviteStatus, | |
| 210 | + from: &[InviteStatus], | |
| 211 | + ) -> Result<fediverse_pending_invite::Model, AppError> { | |
| 212 | + let invite = fediverse_pending_invite::Entity::find_by_id(invite_id) | |
| 213 | + .one(db) | |
| 214 | + .await? | |
| 215 | + .ok_or_else(|| { | |
| 216 | + AppError::NotFound(format!("Invite {invite_id} not found")) | |
| 217 | + })?; | |
| 218 | + let current = InviteStatus::parse(&invite.status); | |
| 219 | + if !from.contains(¤t) { | |
| 220 | + return Err(AppError::Validation(format!( | |
| 221 | + "Cannot transition: invite is in state {}", | |
| 222 | + invite.status | |
| 223 | + ))); | |
| 224 | + } | |
| 225 | + let mut active: fediverse_pending_invite::ActiveModel = invite.into(); | |
| 226 | + active.status = Set(target.as_str().to_string()); | |
| 227 | + active.decided_at = Set(Some(Utc::now())); | |
| 228 | + Ok(active.update(db).await?) | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +/// Build the AP `Invite{TeamMembership}` JSON-LD payload. | |
| 233 | +pub fn build_invite_payload( | |
| 234 | + config: &FediverseConfig, | |
| 235 | + invite: &fediverse_pending_invite::Model, | |
| 236 | + team: &team::Model, | |
| 237 | +) -> serde_json::Value { | |
| 238 | + serde_json::json!({ | |
| 239 | + "@context": [ | |
| 240 | + "https://www.w3.org/ns/activitystreams", | |
| 241 | + "https://schema.rustwarden.org/team-membership/v1" | |
| 242 | + ], | |
| 243 | + "type": "Invite", | |
| 244 | + "id": invite.activity_id, | |
| 245 | + "actor": format!("{}", config.base_url()), | |
| 246 | + "object": { | |
| 247 | + "type": "TeamMembership", | |
| 248 | + "team": { | |
| 249 | + "id": format!("{}teams/{}", config.base_url(), team.id), | |
| 250 | + "name": team.name, | |
| 251 | + }, | |
| 252 | + "role": invite.role, | |
| 253 | + }, | |
| 254 | + "target": invite.invited_handle, | |
| 255 | + "expiresAt": invite.expires_at.to_rfc3339(), | |
| 256 | + }) | |
| 257 | +} | |
| 258 | + | |
| 259 | +/// Build the AP `Accept{Invite}` activity sent back from the IdP. | |
| 260 | +pub fn build_accept_invite_payload( | |
| 261 | + config: &FediverseConfig, | |
| 262 | + invite_activity_id: &str, | |
| 263 | +) -> serde_json::Value { | |
| 264 | + serde_json::json!({ | |
| 265 | + "@context": "https://www.w3.org/ns/activitystreams", | |
| 266 | + "type": "Accept", | |
| 267 | + "actor": format!("{}", config.base_url()), | |
| 268 | + "object": invite_activity_id, | |
| 269 | + }) | |
| 270 | +} | |
| 271 | + | |
| 272 | +/// Build the AP `Reject{Invite}` activity sent back from the IdP. | |
| 273 | +pub fn build_reject_invite_payload( | |
| 274 | + config: &FediverseConfig, | |
| 275 | + invite_activity_id: &str, | |
| 276 | +) -> serde_json::Value { | |
| 277 | + serde_json::json!({ | |
| 278 | + "@context": "https://www.w3.org/ns/activitystreams", | |
| 279 | + "type": "Reject", | |
| 280 | + "actor": format!("{}", config.base_url()), | |
| 281 | + "object": invite_activity_id, | |
| 282 | + }) | |
| 283 | +} | |
| 284 | + | |
| 285 | +/// Build the AP `Revoke{Membership}` activity for either side. | |
| 286 | +pub fn build_revoke_membership_payload( | |
| 287 | + config: &FediverseConfig, | |
| 288 | + membership_id: Uuid, | |
| 289 | + team_id: Uuid, | |
| 290 | +) -> serde_json::Value { | |
| 291 | + serde_json::json!({ | |
| 292 | + "@context": "https://www.w3.org/ns/activitystreams", | |
| 293 | + "type": "Revoke", | |
| 294 | + "actor": format!("{}", config.base_url()), | |
| 295 | + "object": { | |
| 296 | + "type": "TeamMembership", | |
| 297 | + "id": membership_id, | |
| 298 | + "team": format!("{}teams/{}", config.base_url(), team_id), | |
| 299 | + }, | |
| 300 | + }) | |
| 301 | +} | |
| 302 | + | |
| 303 | +#[cfg(test)] | |
| 304 | +mod tests { | |
| 305 | + use super::*; | |
| 306 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 307 | + | |
| 308 | + fn fresh_pending(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model { | |
| 309 | + let now = Utc::now(); | |
| 310 | + fediverse_pending_invite::Model { | |
| 311 | + id, | |
| 312 | + activity_id: format!("https://b.tld/invites/{id}"), | |
| 313 | + team_id, | |
| 314 | + invited_handle: "alice@a.tld".into(), | |
| 315 | + invited_remote_identity_id: None, | |
| 316 | + role: "member".into(), | |
| 317 | + invited_by_user_id: Uuid::new_v4(), | |
| 318 | + status: "pending".into(), | |
| 319 | + created_at: now, | |
| 320 | + expires_at: now + ChronoDuration::days(7), | |
| 321 | + decided_at: None, | |
| 322 | + } | |
| 323 | + } | |
| 324 | + | |
| 325 | + fn test_fediverse_config() -> FediverseConfig { | |
| 326 | + FediverseConfig::for_tests("test.local") | |
| 327 | + } | |
| 328 | + | |
| 329 | + fn test_team(id: Uuid) -> team::Model { | |
| 330 | + team::Model { | |
| 331 | + id, | |
| 332 | + slug: "engineering".into(), | |
| 333 | + name: "Engineering".into(), | |
| 334 | + owner_id: Uuid::new_v4(), | |
| 335 | + created_at: Utc::now(), | |
| 336 | + } | |
| 337 | + } | |
| 338 | + | |
| 339 | + #[test] | |
| 340 | + fn invite_status_round_trips_through_string() { | |
| 341 | + for s in [ | |
| 342 | + InviteStatus::Pending, | |
| 343 | + InviteStatus::Accepted, | |
| 344 | + InviteStatus::Rejected, | |
| 345 | + InviteStatus::Revoked, | |
| 346 | + InviteStatus::Expired, | |
| 347 | + ] { | |
| 348 | + assert_eq!(InviteStatus::parse(s.as_str()), s); | |
| 349 | + } | |
| 350 | + assert_eq!(InviteStatus::parse("garbage"), InviteStatus::Pending); | |
| 351 | + } | |
| 352 | + | |
| 353 | + #[test] | |
| 354 | + fn build_invite_payload_includes_team_and_handle() { | |
| 355 | + let config = test_fediverse_config(); | |
| 356 | + let team_id = Uuid::new_v4(); | |
| 357 | + let invite = fresh_pending(Uuid::new_v4(), team_id); | |
| 358 | + let team = test_team(team_id); | |
| 359 | + let payload = build_invite_payload(&config, &invite, &team); | |
| 360 | + assert_eq!(payload["type"], serde_json::json!("Invite")); | |
| 361 | + assert_eq!(payload["target"], serde_json::json!("alice@a.tld")); | |
| 362 | + assert_eq!(payload["object"]["role"], serde_json::json!("member")); | |
| 363 | + assert_eq!(payload["object"]["team"]["name"], serde_json::json!("Engineering")); | |
| 364 | + } | |
| 365 | + | |
| 366 | + #[test] | |
| 367 | + fn build_accept_invite_payload_references_invite_id() { | |
| 368 | + let config = test_fediverse_config(); | |
| 369 | + let payload = build_accept_invite_payload(&config, "https://b.tld/invites/x"); | |
| 370 | + assert_eq!(payload["type"], serde_json::json!("Accept")); | |
| 371 | + assert_eq!(payload["object"], serde_json::json!("https://b.tld/invites/x")); | |
| 372 | + } | |
| 373 | + | |
| 374 | + #[tokio::test] | |
| 375 | + async fn invite_remote_persists_pending_row() { | |
| 376 | + let row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); | |
| 377 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 378 | + .append_query_results([vec![row.clone()]]) | |
| 379 | + .into_connection(); | |
| 380 | + let inserted = FediverseInviteService::invite_remote( | |
| 381 | + &db, | |
| 382 | + row.team_id, | |
| 383 | + "alice@a.tld", | |
| 384 | + TeamRole::Member, | |
| 385 | + row.invited_by_user_id, | |
| 386 | + Duration::from_secs(7 * 86400), | |
| 387 | + ) | |
| 388 | + .await | |
| 389 | + .unwrap(); | |
| 390 | + assert_eq!(inserted.status, "pending"); | |
| 391 | + } | |
| 392 | + | |
| 393 | + #[tokio::test] | |
| 394 | + async fn invite_remote_uses_provided_role() { | |
| 395 | + let mut row = fresh_pending(Uuid::new_v4(), Uuid::new_v4()); | |
| 396 | + row.role = "admin".into(); | |
| 397 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 398 | + .append_query_results([vec![row.clone()]]) | |
| 399 | + .into_connection(); | |
| 400 | + let inserted = FediverseInviteService::invite_remote( | |
| 401 | + &db, | |
| 402 | + row.team_id, | |
| 403 | + "alice@a.tld", | |
| 404 | + TeamRole::Admin, | |
| 405 | + row.invited_by_user_id, | |
| 406 | + Duration::from_secs(86400), | |
| 407 | + ) | |
| 408 | + .await | |
| 409 | + .unwrap(); | |
| 410 | + assert_eq!(inserted.role, "admin"); | |
| 411 | + } | |
| 412 | + | |
| 413 | + #[tokio::test] | |
| 414 | + async fn accept_marks_invite_accepted_and_inserts_team_member() { | |
| 415 | + let invite_id = Uuid::new_v4(); | |
| 416 | + let team_id = Uuid::new_v4(); | |
| 417 | + let remote_id = Uuid::new_v4(); | |
| 418 | + let invite = fresh_pending(invite_id, team_id); | |
| 419 | + | |
| 420 | + let mut after_invite = invite.clone(); | |
| 421 | + after_invite.status = "accepted".into(); | |
| 422 | + after_invite.invited_remote_identity_id = Some(remote_id); | |
| 423 | + after_invite.decided_at = Some(Utc::now()); | |
| 424 | + | |
| 425 | + let now = Utc::now(); | |
| 426 | + let new_member = team_member::Model { | |
| 427 | + id: Uuid::new_v4(), | |
| 428 | + team_id, | |
| 429 | + user_id: None, | |
| 430 | + role: "member".into(), | |
| 431 | + joined_at: now, | |
| 432 | + remote_identity_id: Some(remote_id), | |
| 433 | + }; | |
| 434 | + | |
| 435 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 436 | + // 1. SELECT invite | |
| 437 | + .append_query_results([vec![invite]]) | |
| 438 | + // 2. SELECT issued by .update() on invite | |
| 439 | + .append_query_results([vec![after_invite]]) | |
| 440 | + // 3. INSERT into team_members → returned row | |
| 441 | + .append_query_results([vec![new_member.clone()]]) | |
| 442 | + .append_exec_results([ | |
| 443 | + MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 444 | + MockExecResult { last_insert_id: 0, rows_affected: 1 }, | |
| 445 | + ]) | |
| 446 | + .into_connection(); | |
| 447 | + | |
| 448 | + let member = FediverseInviteService::accept(&db, invite_id, remote_id) | |
| 449 | + .await | |
| 450 | + .unwrap(); | |
| 451 | + assert_eq!(member.remote_identity_id, Some(remote_id)); | |
| 452 | + assert_eq!(member.team_id, team_id); | |
| 453 | + } | |
| 454 | + | |
| 455 | + #[tokio::test] | |
| 456 | + async fn accept_refuses_non_pending_invite() { | |
| 457 | + let invite_id = Uuid::new_v4(); | |
| 458 | + let mut invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 459 | + invite.status = "rejected".into(); | |
| 460 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 461 | + .append_query_results([vec![invite]]) | |
| 462 | + .into_connection(); | |
| 463 | + let err = FediverseInviteService::accept(&db, invite_id, Uuid::new_v4()) | |
| 464 | + .await | |
| 465 | + .expect_err("must refuse non-pending"); | |
| 466 | + assert!(matches!(err, AppError::Validation(_))); | |
| 467 | + } | |
| 468 | + | |
| 469 | + #[tokio::test] | |
| 470 | + async fn reject_marks_rejected() { | |
| 471 | + let invite_id = Uuid::new_v4(); | |
| 472 | + let invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 473 | + let mut after = invite.clone(); | |
| 474 | + after.status = "rejected".into(); | |
| 475 | + after.decided_at = Some(Utc::now()); | |
| 476 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 477 | + .append_query_results([vec![invite]]) | |
| 478 | + .append_query_results([vec![after]]) | |
| 479 | + .append_exec_results([MockExecResult { | |
| 480 | + last_insert_id: 0, | |
| 481 | + rows_affected: 1, | |
| 482 | + }]) | |
| 483 | + .into_connection(); | |
| 484 | + let updated = FediverseInviteService::reject(&db, invite_id).await.unwrap(); | |
| 485 | + assert_eq!(updated.status, "rejected"); | |
| 486 | + } | |
| 487 | + | |
| 488 | + #[tokio::test] | |
| 489 | + async fn revoke_marks_revoked_when_pending() { | |
| 490 | + let invite_id = Uuid::new_v4(); | |
| 491 | + let invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 492 | + let mut after = invite.clone(); | |
| 493 | + after.status = "revoked".into(); | |
| 494 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 495 | + .append_query_results([vec![invite]]) | |
| 496 | + .append_query_results([vec![after]]) | |
| 497 | + .append_exec_results([MockExecResult { | |
| 498 | + last_insert_id: 0, | |
| 499 | + rows_affected: 1, | |
| 500 | + }]) | |
| 501 | + .into_connection(); | |
| 502 | + let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap(); | |
| 503 | + assert_eq!(updated.status, "revoked"); | |
| 504 | + } | |
| 505 | + | |
| 506 | + #[tokio::test] | |
| 507 | + async fn revoke_marks_revoked_when_already_accepted() { | |
| 508 | + let invite_id = Uuid::new_v4(); | |
| 509 | + let mut invite = fresh_pending(invite_id, Uuid::new_v4()); | |
| 510 | + invite.status = "accepted".into(); | |
| 511 | + invite.invited_remote_identity_id = Some(Uuid::new_v4()); | |
| 512 | + let mut after = invite.clone(); | |
| 513 | + after.status = "revoked".into(); | |
| 514 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 515 | + .append_query_results([vec![invite]]) | |
| 516 | + .append_query_results([vec![after]]) | |
| 517 | + .append_exec_results([MockExecResult { | |
| 518 | + last_insert_id: 0, | |
| 519 | + rows_affected: 1, | |
| 520 | + }]) | |
| 521 | + .into_connection(); | |
| 522 | + let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap(); | |
| 523 | + assert_eq!(updated.status, "revoked"); | |
| 524 | + } | |
| 525 | + | |
| 526 | + #[tokio::test] | |
| 527 | + async fn list_pending_for_team_returns_only_pending() { | |
| 528 | + let team_id = Uuid::new_v4(); | |
| 529 | + let p1 = fresh_pending(Uuid::new_v4(), team_id); | |
| 530 | + let p2 = fresh_pending(Uuid::new_v4(), team_id); | |
| 531 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 532 | + .append_query_results([vec![p1.clone(), p2.clone()]]) | |
| 533 | + .into_connection(); | |
| 534 | + let result = FediverseInviteService::list_pending_for_team(&db, team_id) | |
| 535 | + .await | |
| 536 | + .unwrap(); | |
| 537 | + assert_eq!(result.len(), 2); | |
| 538 | + assert!(result.iter().all(|r| r.status == "pending")); | |
| 539 | + } | |
| 540 | +} |
A
crates/rustwarden-fediverse/src/services/fediverse_outbound_service.rs
+589
-0
@@ -0,0 +1,589 @@
| 1 | +//! Outbound ActivityPub delivery queue + worker (RFC 0011). | |
| 2 | +//! | |
| 3 | +//! Persistent journal of activities that must be delivered to remote | |
| 4 | +//! inboxes (auth `Accept{AuthRequest}`, invitations, lifecycle signals). | |
| 5 | +//! The transport layer is abstracted behind [`OutboundDelivery`] so | |
| 6 | +//! that RFC 0010 can plug in HTTP-Sig signing without disturbing the | |
| 7 | +//! queue/worker plumbing. | |
| 8 | +//! | |
| 9 | +//! Gated by `experimental-fediverse`. | |
| 10 | + | |
| 11 | +use async_trait::async_trait; | |
| 12 | +use chrono::Utc; | |
| 13 | +use sea_orm::{ | |
| 14 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ExprTrait, QueryFilter, | |
| 15 | + QueryOrder, Set, | |
| 16 | +}; | |
| 17 | +use std::time::Duration; | |
| 18 | +use uuid::Uuid; | |
| 19 | + | |
| 20 | +use rustwarden_core::error::AppError; | |
| 21 | +use crate::models::fediverse_outbound_queue; | |
| 22 | + | |
| 23 | +/// Lifecycle of an outbound queue row. | |
| 24 | +/// | |
| 25 | +/// In the Phase-2 slice livered by RFC 0011 only `Pending → Delivered` | |
| 26 | +/// and `Pending → Failed` transitions occur. RFC 0010 introduces the | |
| 27 | +/// retry/back-off ladder that uses `Failed → Pending` and the terminal | |
| 28 | +/// `Failed → Dead`. | |
| 29 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 30 | +pub enum OutboundStatus { | |
| 31 | + /// Eligible for delivery. | |
| 32 | + Pending, | |
| 33 | + /// Successfully delivered. | |
| 34 | + Delivered, | |
| 35 | + /// Last attempt failed; not (yet) eligible for retry. | |
| 36 | + Failed, | |
| 37 | + /// Reached `max_attempts`, no further attempts will be made. | |
| 38 | + Dead, | |
| 39 | +} | |
| 40 | + | |
| 41 | +impl OutboundStatus { | |
| 42 | + /// RFC 0010 retry ladder. The returned offset is added to `now` | |
| 43 | + /// to compute `next_retry_at` after a failed delivery. Sequence: | |
| 44 | + /// 5 min → 30 min → 2 h → 6 h → 24 h → 48 h, capped at 48 h. | |
| 45 | + /// `attempts` is the **post-increment** value (number of failed | |
| 46 | + /// attempts so far). | |
| 47 | + pub fn next_retry_offset(attempts: i32) -> std::time::Duration { | |
| 48 | + const LADDER_SECS: &[u64] = &[ | |
| 49 | + 5 * 60, | |
| 50 | + 30 * 60, | |
| 51 | + 2 * 3600, | |
| 52 | + 6 * 3600, | |
| 53 | + 24 * 3600, | |
| 54 | + 48 * 3600, | |
| 55 | + ]; | |
| 56 | + let bounded = std::cmp::Ord::max(attempts, 1) as usize - 1; | |
| 57 | + let idx = bounded.min(LADDER_SECS.len() - 1); | |
| 58 | + std::time::Duration::from_secs(LADDER_SECS[idx]) | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Wire form stored in the `status` column. | |
| 62 | + pub fn as_str(self) -> &'static str { | |
| 63 | + match self { | |
| 64 | + Self::Pending => "pending", | |
| 65 | + Self::Delivered => "delivered", | |
| 66 | + Self::Failed => "failed", | |
| 67 | + Self::Dead => "dead", | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Best-effort parse — unknown values fall back to `Pending` so a | |
| 72 | + /// data corruption never silently drops a row. | |
| 73 | + pub fn parse(raw: &str) -> Self { | |
| 74 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 75 | + "delivered" => Self::Delivered, | |
| 76 | + "failed" => Self::Failed, | |
| 77 | + "dead" => Self::Dead, | |
| 78 | + _ => Self::Pending, | |
| 79 | + } | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +/// Caller-supplied metadata used to enqueue an activity for delivery. | |
| 84 | +#[derive(Debug, Clone)] | |
| 85 | +pub struct EnqueueRequest { | |
| 86 | + /// Canonical AP `id` of the activity (e.g. `https://a.tld/activities/uuid`). | |
| 87 | + pub activity_id: String, | |
| 88 | + /// Pre-rendered JSON-LD body that will be POSTed to `target_inbox`. | |
| 89 | + pub payload_json: String, | |
| 90 | + /// Inbox URL. | |
| 91 | + pub target_inbox: String, | |
| 92 | + /// Domain owning the inbox (used by trust checks). | |
| 93 | + pub target_domain: String, | |
| 94 | + /// Local actor on whose behalf the activity is signed. Nullable | |
| 95 | + /// while only `NoopDelivery` is wired; required by RFC 0010. | |
| 96 | + pub actor_user_id: Option<Uuid>, | |
| 97 | +} | |
| 98 | + | |
| 99 | +/// Transport layer abstraction. | |
| 100 | +/// | |
| 101 | +/// The Phase-2 slice ships only [`NoopDelivery`]. RFC 0010 will swap in | |
| 102 | +/// `HttpSignedDelivery` that uses RFC 0005 keys to sign requests. | |
| 103 | +#[async_trait] | |
| 104 | +pub trait OutboundDelivery: Send + Sync { | |
| 105 | + /// Attempt to deliver `item` to its `target_inbox`. Returning `Ok` | |
| 106 | + /// promotes the row to `Delivered`; returning `Err` flips it to | |
| 107 | + /// `Failed` and increments `attempts`. | |
| 108 | + async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError>; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/// No-op transport for the slice livered by RFC 0011: pretends every | |
| 112 | +/// delivery succeeded so the queue/worker plumbing can be exercised | |
| 113 | +/// end-to-end without an HTTP stack. Production deployments must swap | |
| 114 | +/// for `HttpSignedDelivery` once RFC 0010 lands. | |
| 115 | +pub struct NoopDelivery; | |
| 116 | + | |
| 117 | +#[async_trait] | |
| 118 | +impl OutboundDelivery for NoopDelivery { | |
| 119 | + async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError> { | |
| 120 | + tracing::debug!( | |
| 121 | + target = %item.target_inbox, | |
| 122 | + activity = %item.activity_id, | |
| 123 | + "NoopDelivery: pretending delivery succeeded", | |
| 124 | + ); | |
| 125 | + Ok(()) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +/// Stateless service exposing enqueue + drain operations on the | |
| 130 | +/// outbound queue. | |
| 131 | +pub struct FediverseOutboundService; | |
| 132 | + | |
| 133 | +impl FediverseOutboundService { | |
| 134 | + /// Persist a new `pending` row. | |
| 135 | + pub async fn enqueue( | |
| 136 | + db: &DatabaseConnection, | |
| 137 | + request: EnqueueRequest, | |
| 138 | + ) -> Result<fediverse_outbound_queue::Model, AppError> { | |
| 139 | + let now = Utc::now(); | |
| 140 | + let model = fediverse_outbound_queue::ActiveModel { | |
| 141 | + id: Set(Uuid::new_v4()), | |
| 142 | + activity_id: Set(request.activity_id), | |
| 143 | + payload_json: Set(request.payload_json), | |
| 144 | + target_inbox: Set(request.target_inbox), | |
| 145 | + target_domain: Set(request.target_domain), | |
| 146 | + status: Set(OutboundStatus::Pending.as_str().to_string()), | |
| 147 | + attempts: Set(0), | |
| 148 | + max_attempts: Set(8), | |
| 149 | + last_error: Set(None), | |
| 150 | + next_retry_at: Set(None), | |
| 151 | + created_at: Set(now), | |
| 152 | + delivered_at: Set(None), | |
| 153 | + actor_user_id: Set(request.actor_user_id), | |
| 154 | + }; | |
| 155 | + Ok(model.insert(db).await?) | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// Pick one eligible row, hand it to `transport`, and persist the | |
| 159 | + /// outcome. Returns the updated row, or `None` if the queue is | |
| 160 | + /// empty. | |
| 161 | + pub async fn drain_one<T: OutboundDelivery>( | |
| 162 | + db: &DatabaseConnection, | |
| 163 | + transport: &T, | |
| 164 | + ) -> Result<Option<fediverse_outbound_queue::Model>, AppError> { | |
| 165 | + let now = Utc::now(); | |
| 166 | + let candidate = fediverse_outbound_queue::Entity::find() | |
| 167 | + .filter( | |
| 168 | + fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str()), | |
| 169 | + ) | |
| 170 | + .filter( | |
| 171 | + fediverse_outbound_queue::Column::NextRetryAt | |
| 172 | + .is_null() | |
| 173 | + .or(fediverse_outbound_queue::Column::NextRetryAt.lte(now)), | |
| 174 | + ) | |
| 175 | + .order_by_asc(fediverse_outbound_queue::Column::CreatedAt) | |
| 176 | + .one(db) | |
| 177 | + .await?; | |
| 178 | + | |
| 179 | + let Some(model) = candidate else { | |
| 180 | + return Ok(None); | |
| 181 | + }; | |
| 182 | + | |
| 183 | + let delivery = transport.deliver(&model).await; | |
| 184 | + let next_attempts = model.attempts + 1; | |
| 185 | + let max_attempts = model.max_attempts; | |
| 186 | + let model_for_update = model.clone(); | |
| 187 | + let mut active: fediverse_outbound_queue::ActiveModel = model_for_update.into(); | |
| 188 | + active.attempts = Set(next_attempts); | |
| 189 | + match &delivery { | |
| 190 | + Ok(()) => { | |
| 191 | + active.status = Set(OutboundStatus::Delivered.as_str().to_string()); | |
| 192 | + active.delivered_at = Set(Some(Utc::now())); | |
| 193 | + active.last_error = Set(None); | |
| 194 | + active.next_retry_at = Set(None); | |
| 195 | + } | |
| 196 | + Err(err) => { | |
| 197 | + active.last_error = Set(Some(err.to_string())); | |
| 198 | + if next_attempts >= max_attempts { | |
| 199 | + // RFC 0010: terminal — no further retry. | |
| 200 | + active.status = Set(OutboundStatus::Dead.as_str().to_string()); | |
| 201 | + active.next_retry_at = Set(None); | |
| 202 | + } else { | |
| 203 | + // RFC 0010: stay `pending` and gate on `next_retry_at` | |
| 204 | + // so the partial-index scan ignores the row until the | |
| 205 | + // backoff has elapsed. | |
| 206 | + let offset = OutboundStatus::next_retry_offset(next_attempts); | |
| 207 | + let next = Utc::now() | |
| 208 | + + chrono::Duration::from_std(offset).map_err(|e| { | |
| 209 | + AppError::Internal(format!( | |
| 210 | + "retry offset out of range for chrono::Duration: {e}", | |
| 211 | + )) | |
| 212 | + })?; | |
| 213 | + active.status = Set(OutboundStatus::Pending.as_str().to_string()); | |
| 214 | + active.next_retry_at = Set(Some(next)); | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + let updated = active.update(db).await?; | |
| 219 | + | |
| 220 | + // RFC 0019: structured tracing events — drive Prometheus / Loki / | |
| 221 | + // OTEL exporters without coupling rustwarden-core to a metrics | |
| 222 | + // crate. Gated by `app_settings.fediverse_observability_enabled` | |
| 223 | + // (default `false`) — opt-in to avoid log noise on instances | |
| 224 | + // that pipe nothing to an observability backend. | |
| 225 | + let observability = | |
| 226 | + is_fediverse_observability_enabled(db).await.unwrap_or(false); | |
| 227 | + if observability { | |
| 228 | + match (&delivery, updated.status.as_str()) { | |
| 229 | + (Ok(()), _) => { | |
| 230 | + tracing::info!( | |
| 231 | + target: "rustwarden::fediverse::outbound", | |
| 232 | + activity_id = %updated.activity_id, | |
| 233 | + target_inbox = %updated.target_inbox, | |
| 234 | + target_domain = %updated.target_domain, | |
| 235 | + attempts = updated.attempts, | |
| 236 | + event = "delivered", | |
| 237 | + ); | |
| 238 | + } | |
| 239 | + (Err(err), status) if status == OutboundStatus::Dead.as_str() => { | |
| 240 | + tracing::error!( | |
| 241 | + target: "rustwarden::fediverse::outbound", | |
| 242 | + activity_id = %updated.activity_id, | |
| 243 | + target_domain = %updated.target_domain, | |
| 244 | + last_error = %err, | |
| 245 | + attempts = updated.attempts, | |
| 246 | + max_attempts = updated.max_attempts, | |
| 247 | + event = "dead", | |
| 248 | + ); | |
| 249 | + } | |
| 250 | + (Err(err), _) => { | |
| 251 | + tracing::warn!( | |
| 252 | + target: "rustwarden::fediverse::outbound", | |
| 253 | + activity_id = %updated.activity_id, | |
| 254 | + target_domain = %updated.target_domain, | |
| 255 | + last_error = %err, | |
| 256 | + attempts = updated.attempts, | |
| 257 | + next_retry_at = ?updated.next_retry_at, | |
| 258 | + event = "deferred", | |
| 259 | + ); | |
| 260 | + } | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + Ok(Some(updated)) | |
| 265 | + } | |
| 266 | +} | |
| 267 | + | |
| 268 | +/// RFC 0019 + S4 observability toggle — `app_settings.fediverse_observability_enabled`, | |
| 269 | +/// default **false**. When `false`, fediverse routes/services skip the | |
| 270 | +/// structured `tracing::info!` / `warn!` / `error!` emit-points used for | |
| 271 | +/// metric aggregation. Cheap (1 SELECT by key) ; cached by the upstream | |
| 272 | +/// `AppSettingsService` if the consumer wires its standard cache. | |
| 273 | +pub async fn is_fediverse_observability_enabled( | |
| 274 | + db: &DatabaseConnection, | |
| 275 | +) -> Result<bool, AppError> { | |
| 276 | + rustwarden_core::services::app_settings_service::AppSettingsService::get_bool( | |
| 277 | + db, | |
| 278 | + "fediverse_observability_enabled", | |
| 279 | + false, | |
| 280 | + ) | |
| 281 | + .await | |
| 282 | +} | |
| 283 | + | |
| 284 | +/// Background loop that periodically drains the queue. | |
| 285 | +/// | |
| 286 | +/// Spawned by `RustwardenBuilder::with_fediverse()` (RFC 0011 hooks | |
| 287 | +/// the spawn into `with_fediverse` in a follow-up patch — RFC 0006 | |
| 288 | +/// will be the first consumer and triggers the spawn explicitly). | |
| 289 | +pub struct OutboundWorker<T: OutboundDelivery> { | |
| 290 | + db: DatabaseConnection, | |
| 291 | + transport: T, | |
| 292 | + poll_interval: Duration, | |
| 293 | +} | |
| 294 | + | |
| 295 | +impl<T: OutboundDelivery> OutboundWorker<T> { | |
| 296 | + /// Build a worker that polls every `poll_interval`. | |
| 297 | + pub fn new(db: DatabaseConnection, transport: T, poll_interval: Duration) -> Self { | |
| 298 | + Self { | |
| 299 | + db, | |
| 300 | + transport, | |
| 301 | + poll_interval, | |
| 302 | + } | |
| 303 | + } | |
| 304 | + | |
| 305 | + /// Run the polling loop forever. Errors during a single delivery | |
| 306 | + /// are logged but do not stop the loop. Cancellation is achieved | |
| 307 | + /// by cancelling the surrounding task. | |
| 308 | + pub async fn run(self) { | |
| 309 | + loop { | |
| 310 | + match FediverseOutboundService::drain_one(&self.db, &self.transport).await { | |
| 311 | + Ok(Some(_)) => continue, // drain greedily while items exist | |
| 312 | + Ok(None) => tokio::time::sleep(self.poll_interval).await, | |
| 313 | + Err(err) => { | |
| 314 | + tracing::error!( | |
| 315 | + target: "rustwarden::fediverse::outbound", | |
| 316 | + error = %err, | |
| 317 | + event = "drain_failed", | |
| 318 | + ); | |
| 319 | + tokio::time::sleep(self.poll_interval).await; | |
| 320 | + } | |
| 321 | + } | |
| 322 | + } | |
| 323 | + } | |
| 324 | +} | |
| 325 | + | |
| 326 | +#[cfg(test)] | |
| 327 | +mod tests { | |
| 328 | + use super::*; | |
| 329 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 330 | + | |
| 331 | + fn pending_row(id: Uuid) -> fediverse_outbound_queue::Model { | |
| 332 | + let now = Utc::now(); | |
| 333 | + fediverse_outbound_queue::Model { | |
| 334 | + id, | |
| 335 | + activity_id: format!("https://a.tld/activities/{id}"), | |
| 336 | + payload_json: "{}".to_string(), | |
| 337 | + target_inbox: "https://b.tld/inbox".to_string(), | |
| 338 | + target_domain: "b.tld".to_string(), | |
| 339 | + status: "pending".to_string(), | |
| 340 | + attempts: 0, | |
| 341 | + max_attempts: 8, | |
| 342 | + last_error: None, | |
| 343 | + next_retry_at: None, | |
| 344 | + created_at: now, | |
| 345 | + delivered_at: None, | |
| 346 | + actor_user_id: None, | |
| 347 | + } | |
| 348 | + } | |
| 349 | + | |
| 350 | + struct ErringDelivery; | |
| 351 | + | |
| 352 | + #[async_trait] | |
| 353 | + impl OutboundDelivery for ErringDelivery { | |
| 354 | + async fn deliver( | |
| 355 | + &self, | |
| 356 | + _item: &fediverse_outbound_queue::Model, | |
| 357 | + ) -> Result<(), AppError> { | |
| 358 | + Err(AppError::Internal("simulated transport failure".into())) | |
| 359 | + } | |
| 360 | + } | |
| 361 | + | |
| 362 | + #[test] | |
| 363 | + fn outbound_status_round_trips_through_string() { | |
| 364 | + for s in [ | |
| 365 | + OutboundStatus::Pending, | |
| 366 | + OutboundStatus::Delivered, | |
| 367 | + OutboundStatus::Failed, | |
| 368 | + OutboundStatus::Dead, | |
| 369 | + ] { | |
| 370 | + assert_eq!(OutboundStatus::parse(s.as_str()), s); | |
| 371 | + } | |
| 372 | + // Unknown wire value falls back safely. | |
| 373 | + assert_eq!(OutboundStatus::parse("garbage"), OutboundStatus::Pending); | |
| 374 | + } | |
| 375 | + | |
| 376 | + #[test] | |
| 377 | + fn enqueue_request_clone_works() { | |
| 378 | + let r = EnqueueRequest { | |
| 379 | + activity_id: "act".into(), | |
| 380 | + payload_json: "{}".into(), | |
| 381 | + target_inbox: "ib".into(), | |
| 382 | + target_domain: "d".into(), | |
| 383 | + actor_user_id: None, | |
| 384 | + }; | |
| 385 | + let c = r.clone(); | |
| 386 | + assert_eq!(c.activity_id, "act"); | |
| 387 | + } | |
| 388 | + | |
| 389 | + #[tokio::test] | |
| 390 | + async fn enqueue_persists_pending_row() { | |
| 391 | + let id = Uuid::new_v4(); | |
| 392 | + let row = pending_row(id); | |
| 393 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 394 | + .append_query_results([vec![row.clone()]]) | |
| 395 | + .into_connection(); | |
| 396 | + | |
| 397 | + let inserted = FediverseOutboundService::enqueue( | |
| 398 | + &db, | |
| 399 | + EnqueueRequest { | |
| 400 | + activity_id: row.activity_id.clone(), | |
| 401 | + payload_json: row.payload_json.clone(), | |
| 402 | + target_inbox: row.target_inbox.clone(), | |
| 403 | + target_domain: row.target_domain.clone(), | |
| 404 | + actor_user_id: None, | |
| 405 | + }, | |
| 406 | + ) | |
| 407 | + .await | |
| 408 | + .expect("enqueue ok"); | |
| 409 | + assert_eq!(inserted.status, "pending"); | |
| 410 | + assert_eq!(inserted.attempts, 0); | |
| 411 | + } | |
| 412 | + | |
| 413 | + #[tokio::test] | |
| 414 | + async fn enqueue_uses_provided_activity_id() { | |
| 415 | + let id = Uuid::new_v4(); | |
| 416 | + let mut row = pending_row(id); | |
| 417 | + row.activity_id = "https://example.test/specific-id".into(); | |
| 418 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 419 | + .append_query_results([vec![row.clone()]]) | |
| 420 | + .into_connection(); | |
| 421 | + | |
| 422 | + let inserted = FediverseOutboundService::enqueue( | |
| 423 | + &db, | |
| 424 | + EnqueueRequest { | |
| 425 | + activity_id: row.activity_id.clone(), | |
| 426 | + payload_json: "{}".into(), | |
| 427 | + target_inbox: row.target_inbox.clone(), | |
| 428 | + target_domain: row.target_domain.clone(), | |
| 429 | + actor_user_id: None, | |
| 430 | + }, | |
| 431 | + ) | |
| 432 | + .await | |
| 433 | + .unwrap(); | |
| 434 | + assert_eq!(inserted.activity_id, "https://example.test/specific-id"); | |
| 435 | + } | |
| 436 | + | |
| 437 | + #[tokio::test] | |
| 438 | + async fn drain_one_returns_none_when_empty() { | |
| 439 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 440 | + .append_query_results([Vec::<fediverse_outbound_queue::Model>::new()]) | |
| 441 | + .into_connection(); | |
| 442 | + let result = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 443 | + .await | |
| 444 | + .unwrap(); | |
| 445 | + assert!(result.is_none()); | |
| 446 | + } | |
| 447 | + | |
| 448 | + #[tokio::test] | |
| 449 | + async fn drain_one_marks_delivered_on_success() { | |
| 450 | + let id = Uuid::new_v4(); | |
| 451 | + let row = pending_row(id); | |
| 452 | + let mut after = row.clone(); | |
| 453 | + after.status = "delivered".into(); | |
| 454 | + after.attempts = 1; | |
| 455 | + after.delivered_at = Some(Utc::now()); | |
| 456 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 457 | + // SELECT for candidate | |
| 458 | + .append_query_results([vec![row.clone()]]) | |
| 459 | + // Row returned by .update() | |
| 460 | + .append_query_results([vec![after]]) | |
| 461 | + .append_exec_results([MockExecResult { | |
| 462 | + last_insert_id: 0, | |
| 463 | + rows_affected: 1, | |
| 464 | + }]) | |
| 465 | + .into_connection(); | |
| 466 | + | |
| 467 | + let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 468 | + .await | |
| 469 | + .unwrap() | |
| 470 | + .expect("row processed"); | |
| 471 | + assert_eq!(updated.status, "delivered"); | |
| 472 | + assert_eq!(updated.attempts, 1); | |
| 473 | + assert!(updated.last_error.is_none()); | |
| 474 | + } | |
| 475 | + | |
| 476 | + #[tokio::test] | |
| 477 | + async fn drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts() { | |
| 478 | + // RFC 0010: a single failure on a row with attempts < max | |
| 479 | + // keeps the row `pending` but gated on `next_retry_at`. | |
| 480 | + let id = Uuid::new_v4(); | |
| 481 | + let row = pending_row(id); | |
| 482 | + let mut after = row.clone(); | |
| 483 | + after.status = "pending".into(); | |
| 484 | + after.attempts = 1; | |
| 485 | + after.last_error = Some("simulated transport failure".into()); | |
| 486 | + after.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); | |
| 487 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 488 | + .append_query_results([vec![row.clone()]]) | |
| 489 | + .append_query_results([vec![after]]) | |
| 490 | + .append_exec_results([MockExecResult { | |
| 491 | + last_insert_id: 0, | |
| 492 | + rows_affected: 1, | |
| 493 | + }]) | |
| 494 | + .into_connection(); | |
| 495 | + | |
| 496 | + let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery) | |
| 497 | + .await | |
| 498 | + .unwrap() | |
| 499 | + .expect("row processed"); | |
| 500 | + assert_eq!(updated.status, "pending"); | |
| 501 | + assert!(updated.last_error.is_some()); | |
| 502 | + assert!(updated.next_retry_at.is_some()); | |
| 503 | + } | |
| 504 | + | |
| 505 | + #[tokio::test] | |
| 506 | + async fn drain_one_marks_dead_when_attempts_reach_max() { | |
| 507 | + let id = Uuid::new_v4(); | |
| 508 | + let mut row = pending_row(id); | |
| 509 | + row.attempts = 7; | |
| 510 | + row.max_attempts = 8; | |
| 511 | + let mut after = row.clone(); | |
| 512 | + after.status = "dead".into(); | |
| 513 | + after.attempts = 8; | |
| 514 | + after.last_error = Some("simulated transport failure".into()); | |
| 515 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 516 | + .append_query_results([vec![row]]) | |
| 517 | + .append_query_results([vec![after]]) | |
| 518 | + .append_exec_results([MockExecResult { | |
| 519 | + last_insert_id: 0, | |
| 520 | + rows_affected: 1, | |
| 521 | + }]) | |
| 522 | + .into_connection(); | |
| 523 | + | |
| 524 | + let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery) | |
| 525 | + .await | |
| 526 | + .unwrap() | |
| 527 | + .unwrap(); | |
| 528 | + assert_eq!(updated.status, "dead"); | |
| 529 | + assert!(updated.next_retry_at.is_none()); | |
| 530 | + } | |
| 531 | + | |
| 532 | + #[test] | |
| 533 | + fn next_retry_offset_grows_then_caps() { | |
| 534 | + let secs: Vec<u64> = (1..=8) | |
| 535 | + .map(|i| OutboundStatus::next_retry_offset(i).as_secs()) | |
| 536 | + .collect(); | |
| 537 | + // Strictly increasing for the first 6 entries. | |
| 538 | + assert!(secs[0] < secs[1]); | |
| 539 | + assert!(secs[1] < secs[2]); | |
| 540 | + assert!(secs[2] < secs[3]); | |
| 541 | + assert!(secs[3] < secs[4]); | |
| 542 | + assert!(secs[4] < secs[5]); | |
| 543 | + // Cap at 48h afterwards. | |
| 544 | + assert_eq!(secs[5], 48 * 3600); | |
| 545 | + assert_eq!(secs[6], 48 * 3600); | |
| 546 | + assert_eq!(secs[7], 48 * 3600); | |
| 547 | + } | |
| 548 | + | |
| 549 | + #[tokio::test] | |
| 550 | + async fn drain_one_increments_attempts() { | |
| 551 | + let id = Uuid::new_v4(); | |
| 552 | + let mut row = pending_row(id); | |
| 553 | + row.attempts = 3; | |
| 554 | + let mut after = row.clone(); | |
| 555 | + after.status = "delivered".into(); | |
| 556 | + after.attempts = 4; | |
| 557 | + after.delivered_at = Some(Utc::now()); | |
| 558 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 559 | + .append_query_results([vec![row.clone()]]) | |
| 560 | + .append_query_results([vec![after]]) | |
| 561 | + .append_exec_results([MockExecResult { | |
| 562 | + last_insert_id: 0, | |
| 563 | + rows_affected: 1, | |
| 564 | + }]) | |
| 565 | + .into_connection(); | |
| 566 | + | |
| 567 | + let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery) | |
| 568 | + .await | |
| 569 | + .unwrap() | |
| 570 | + .unwrap(); | |
| 571 | + assert_eq!(updated.attempts, 4); | |
| 572 | + } | |
| 573 | + | |
| 574 | + #[tokio::test] | |
| 575 | + async fn noop_delivery_returns_ok() { | |
| 576 | + let row = pending_row(Uuid::new_v4()); | |
| 577 | + assert!(NoopDelivery.deliver(&row).await.is_ok()); | |
| 578 | + } | |
| 579 | + | |
| 580 | + #[tokio::test] | |
| 581 | + async fn worker_can_be_built_and_dropped() { | |
| 582 | + // Smoke test : building the struct and dropping it before the | |
| 583 | + // loop runs must not panic. | |
| 584 | + let db: DatabaseConnection = | |
| 585 | + MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 586 | + let _worker = OutboundWorker::new(db, NoopDelivery, Duration::from_millis(10)); | |
| 587 | + // Drop happens at end of scope. | |
| 588 | + } | |
| 589 | +} |
A
crates/rustwarden-fediverse/src/services/fediverse_signal_service.rs
+1028
-0
@@ -0,0 +1,1028 @@
| 1 | +//! Fediverse lifecycle signal handlers (RFC 0010). | |
| 2 | +//! | |
| 3 | +//! Applies incoming AP `Update{Actor}` / `Delete{Actor}` / | |
| 4 | +//! `Revoke{Membership}` signals to the local state. The HTTP-Sig | |
| 5 | +//! verification of the request that carried the signal is delegated to | |
| 6 | +//! [`SignatureVerifier`] — the implementation that ships with this RFC | |
| 7 | +//! is `AlwaysAcceptVerifier` (test-only); a follow-up RFC will plug | |
| 8 | +//! the real RFC 9421 verifier. | |
| 9 | +//! | |
| 10 | +//! Gated by `experimental-fediverse`. | |
| 11 | + | |
| 12 | +use async_trait::async_trait; | |
| 13 | +use chrono::Utc; | |
| 14 | +use sea_orm::{ | |
| 15 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, | |
| 16 | +}; | |
| 17 | +use uuid::Uuid; | |
| 18 | + | |
| 19 | +use rustwarden_core::error::AppError; | |
| 20 | +use crate::models::remote_identity; | |
| 21 | +use rustwarden_core::models::team_member; | |
| 22 | + | |
| 23 | +/// Carries the actor metadata extracted from an inbound `Update{Actor}` | |
| 24 | +/// activity. | |
| 25 | +#[derive(Debug, Clone)] | |
| 26 | +pub struct UpdatedActor { | |
| 27 | + /// Canonical AP `id`. Match key against `remote_identities.ap_id`. | |
| 28 | + pub ap_id: String, | |
| 29 | + /// Optional display name (`name` field on the AP `Person`). | |
| 30 | + pub display_name: Option<String>, | |
| 31 | + /// Optional bio (`summary` field). | |
| 32 | + pub summary_html: Option<String>, | |
| 33 | + /// Optional avatar URL (`icon.url`). | |
| 34 | + pub icon_url: Option<String>, | |
| 35 | + /// Optional refreshed public key. | |
| 36 | + pub public_key_pem: Option<String>, | |
| 37 | + /// Optional raw activity body for audit / re-processing. | |
| 38 | + pub raw_json: Option<serde_json::Value>, | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// Counters returned by [`FediverseSignalService::apply_delete_actor`]. | |
| 42 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 43 | +pub struct DeleteOutcome { | |
| 44 | + /// Number of `remote_identities` rows soft-deleted. | |
| 45 | + pub rows_disabled: u64, | |
| 46 | + /// Number of JWTs blacklisted (always `0` for the RFC 0010 slice; | |
| 47 | + /// a complementary `JwtBlacklistService::blacklist_by_remote_identity_id` | |
| 48 | + /// API will close that gap in a follow-up). | |
| 49 | + pub jwts_blacklisted: u64, | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// Stateless service that applies AP lifecycle signals. | |
| 53 | +pub struct FediverseSignalService; | |
| 54 | + | |
| 55 | +impl FediverseSignalService { | |
| 56 | + /// Apply an inbound `Update{Actor}`: upsert the row keyed by | |
| 57 | + /// `ap_id`. The local fields not carried by `actor` are preserved | |
| 58 | + /// (no destructive overwrite). | |
| 59 | + pub async fn apply_update_actor( | |
| 60 | + db: &DatabaseConnection, | |
| 61 | + actor: UpdatedActor, | |
| 62 | + ) -> Result<remote_identity::Model, AppError> { | |
| 63 | + let existing = remote_identity::Entity::find() | |
| 64 | + .filter(remote_identity::Column::ApId.eq(actor.ap_id.clone())) | |
| 65 | + .one(db) | |
| 66 | + .await?; | |
| 67 | + | |
| 68 | + let now = Utc::now(); | |
| 69 | + if let Some(model) = existing { | |
| 70 | + let mut active: remote_identity::ActiveModel = model.into(); | |
| 71 | + if let Some(name) = actor.display_name { | |
| 72 | + active.display_name = Set(Some(name)); | |
| 73 | + } | |
| 74 | + if let Some(summary) = actor.summary_html { | |
| 75 | + active.summary_html = Set(Some(summary)); | |
| 76 | + } | |
| 77 | + if let Some(icon) = actor.icon_url { | |
| 78 | + active.icon_url = Set(Some(icon)); | |
| 79 | + } | |
| 80 | + if let Some(pem) = actor.public_key_pem { | |
| 81 | + active.public_key_pem = Set(pem); | |
| 82 | + } | |
| 83 | + if let Some(raw) = actor.raw_json { | |
| 84 | + active.raw_json = Set(Some(raw)); | |
| 85 | + } | |
| 86 | + active.last_fetched_at = Set(now); | |
| 87 | + Ok(active.update(db).await?) | |
| 88 | + } else { | |
| 89 | + // Minimal insert. The caller (inbox handler) is responsible | |
| 90 | + // for filling in the rest via webfinger lookup before | |
| 91 | + // calling apply_update_actor — but if the row truly is new | |
| 92 | + // we still want to persist *something* rather than 404. | |
| 93 | + let username_domain = parse_handle_from_ap_id(&actor.ap_id).unwrap_or_else(|| { | |
| 94 | + ("unknown".to_string(), "unknown".to_string()) | |
| 95 | + }); | |
| 96 | + let active = remote_identity::ActiveModel { | |
| 97 | + id: Set(Uuid::new_v4()), | |
| 98 | + ap_id: Set(actor.ap_id.clone()), | |
| 99 | + username: Set(username_domain.0), | |
| 100 | + domain: Set(username_domain.1), | |
| 101 | + display_name: Set(actor.display_name), | |
| 102 | + summary_html: Set(actor.summary_html), | |
| 103 | + inbox_url: Set(format!("{}/inbox", actor.ap_id)), | |
| 104 | + shared_inbox_url: Set(None), | |
| 105 | + public_key_pem: Set(actor.public_key_pem.unwrap_or_default()), | |
| 106 | + icon_url: Set(actor.icon_url), | |
| 107 | + actor_type: Set("Person".to_string()), | |
| 108 | + raw_json: Set(actor.raw_json), | |
| 109 | + last_fetched_at: Set(now), | |
| 110 | + disabled_at: Set(None), | |
| 111 | + created_at: Set(now), | |
| 112 | + updated_at: Set(now), | |
| 113 | + }; | |
| 114 | + Ok(active.insert(db).await?) | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + /// Apply an inbound `Delete{Actor}`: flip `disabled_at` to NOW for | |
| 119 | + /// the matching `remote_identities` row. JWT blacklisting is | |
| 120 | + /// returned as `0` — see [`DeleteOutcome::jwts_blacklisted`]. | |
| 121 | + pub async fn apply_delete_actor( | |
| 122 | + db: &DatabaseConnection, | |
| 123 | + ap_id: &str, | |
| 124 | + ) -> Result<DeleteOutcome, AppError> { | |
| 125 | + let model = remote_identity::Entity::find() | |
| 126 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 127 | + .one(db) | |
| 128 | + .await?; | |
| 129 | + | |
| 130 | + let Some(row) = model else { | |
| 131 | + return Ok(DeleteOutcome::default()); | |
| 132 | + }; | |
| 133 | + | |
| 134 | + if row.disabled_at.is_some() { | |
| 135 | + return Ok(DeleteOutcome { | |
| 136 | + rows_disabled: 0, | |
| 137 | + jwts_blacklisted: 0, | |
| 138 | + }); | |
| 139 | + } | |
| 140 | + | |
| 141 | + let mut active: remote_identity::ActiveModel = row.into(); | |
| 142 | + active.disabled_at = Set(Some(Utc::now())); | |
| 143 | + active.update(db).await?; | |
| 144 | + | |
| 145 | + Ok(DeleteOutcome { | |
| 146 | + rows_disabled: 1, | |
| 147 | + jwts_blacklisted: 0, | |
| 148 | + }) | |
| 149 | + } | |
| 150 | + | |
| 151 | + /// RFC 0018 extension: same as [`Self::apply_delete_actor`] but | |
| 152 | + /// also blacklists every active JWT issued for the deleted remote | |
| 153 | + /// identity. The `iterator` argument is pluggable | |
| 154 | + /// ([`rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator`]) — | |
| 155 | + /// pass `&NoopActiveTokenIterator` to keep the legacy behaviour. | |
| 156 | + pub async fn apply_delete_actor_with_iterator( | |
| 157 | + db: &DatabaseConnection, | |
| 158 | + ap_id: &str, | |
| 159 | + iterator: &dyn rustwarden_core::services::jwt_blacklist_service::ActiveTokenIterator, | |
| 160 | + ) -> Result<DeleteOutcome, AppError> { | |
| 161 | + let mut outcome = Self::apply_delete_actor(db, ap_id).await?; | |
| 162 | + if outcome.rows_disabled == 0 { | |
| 163 | + return Ok(outcome); | |
| 164 | + } | |
| 165 | + // Re-fetch the row to learn its `id` (the `apply_delete_actor` | |
| 166 | + // call above only returned counters). | |
| 167 | + let model = remote_identity::Entity::find() | |
| 168 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 169 | + .one(db) | |
| 170 | + .await?; | |
| 171 | + if let Some(row) = model { | |
| 172 | + outcome.jwts_blacklisted = rustwarden_core::services::jwt_blacklist_service::JwtBlacklistService::blacklist_by_remote_identity_id( | |
| 173 | + db, | |
| 174 | + row.id, | |
| 175 | + iterator, | |
| 176 | + ) | |
| 177 | + .await?; | |
| 178 | + } | |
| 179 | + Ok(outcome) | |
| 180 | + } | |
| 181 | + | |
| 182 | + /// Apply an inbound `Revoke{Membership}`: drop the matching | |
| 183 | + /// `team_members` row. Returns `true` if a row was deleted. | |
| 184 | + pub async fn apply_revoke_membership( | |
| 185 | + db: &DatabaseConnection, | |
| 186 | + team_id: Uuid, | |
| 187 | + remote_identity_id: Uuid, | |
| 188 | + ) -> Result<bool, AppError> { | |
| 189 | + let result = team_member::Entity::delete_many() | |
| 190 | + .filter(team_member::Column::TeamId.eq(team_id)) | |
| 191 | + .filter( | |
| 192 | + team_member::Column::RemoteIdentityId | |
| 193 | + .eq(Some(remote_identity_id)), | |
| 194 | + ) | |
| 195 | + .exec(db) | |
| 196 | + .await?; | |
| 197 | + Ok(result.rows_affected > 0) | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +/// Inbound request context passed to [`SignatureVerifier::verify`] | |
| 202 | +/// (RFC 0016). Carries every field needed by draft-cavage HTTP | |
| 203 | +/// signature verification: method + path + headers + body + the | |
| 204 | +/// actor's resolved public key. | |
| 205 | +#[derive(Debug)] | |
| 206 | +pub struct VerifyCtx<'a> { | |
| 207 | + /// AP id of the signer (extracted from the body by the caller). | |
| 208 | + pub actor_ap_id: &'a str, | |
| 209 | + /// HTTP method, lowercased (`"post"`). | |
| 210 | + pub method: &'a str, | |
| 211 | + /// Path-and-query (`"/inbox"`). | |
| 212 | + pub path: &'a str, | |
| 213 | + /// All inbound headers (case-insensitive lookup at the caller). | |
| 214 | + pub headers: &'a axum::http::HeaderMap, | |
| 215 | + /// Raw body bytes (used by the caller for digest / audit). | |
| 216 | + pub body: &'a [u8], | |
| 217 | + /// Resolved PEM public key for `actor_ap_id`. Empty when the | |
| 218 | + /// caller has not yet resolved the actor; impls may either | |
| 219 | + /// reject or fetch on their own. | |
| 220 | + pub public_key_pem: &'a str, | |
| 221 | +} | |
| 222 | + | |
| 223 | +/// HTTP-Sig verifier for inbound AP activities. | |
| 224 | +/// | |
| 225 | +/// Implementations decide whether to accept a given signature. The | |
| 226 | +/// `AlwaysAcceptVerifier` shipped here is **test/dev only**. | |
| 227 | +/// `HttpSigVerifier` from | |
| 228 | +/// [`crate::fediverse::http_signatures`] is the production | |
| 229 | +/// implementation. | |
| 230 | +#[async_trait] | |
| 231 | +pub trait SignatureVerifier: Send + Sync { | |
| 232 | + /// Verify a signature given the full request context. | |
| 233 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError>; | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// Test/dev verifier that accepts any signature. | |
| 237 | +/// | |
| 238 | +/// **Never use in production.** A logged warning emphasises the | |
| 239 | +/// hazard at instantiation in production builds. | |
| 240 | +/// | |
| 241 | +/// When constructed with [`AlwaysAcceptVerifier::with_resolver`], the | |
| 242 | +/// verifier also fetches the actor over HTTP and upserts the | |
| 243 | +/// `remote_identities` row before returning `Ok(())` — this preserves | |
| 244 | +/// the contract documented by [`crate::services::fediverse_auth_service::FediverseAuthService::persist_inbound_request`] | |
| 245 | +/// (« the signature verifier ran upstream and already required this | |
| 246 | +/// row ») so downstream services don't need to special-case the debug | |
| 247 | +/// verifier. | |
| 248 | +pub struct AlwaysAcceptVerifier { | |
| 249 | + resolver: Option<(DatabaseConnection, reqwest::Client)>, | |
| 250 | +} | |
| 251 | + | |
| 252 | +impl AlwaysAcceptVerifier { | |
| 253 | + /// Verifier with no side-effects. Suitable for unit tests where the | |
| 254 | + /// caller pre-seeds `remote_identities` themselves. | |
| 255 | + pub fn new() -> Self { | |
| 256 | + Self { resolver: None } | |
| 257 | + } | |
| 258 | + | |
| 259 | + /// Verifier that, on each accepted request, fetches the actor doc | |
| 260 | + /// from `ctx.actor_ap_id` over HTTP and upserts the corresponding | |
| 261 | + /// `remote_identities` cache row. Best-effort: a fetch/parse failure | |
| 262 | + /// is logged and the request is still accepted (since this verifier | |
| 263 | + /// is « always accept » by contract). | |
| 264 | + /// | |
| 265 | + /// The internal HTTP client has [`ACTOR_FETCH_TIMEOUT`] applied. The | |
| 266 | + /// SSRF guard inside [`fetch_and_cache_remote_actor`] is **bypassed** | |
| 267 | + /// here (`allow_private_ips=true`) because this verifier is test/dev | |
| 268 | + /// only and the docker-compose testbed lives on private IPs. | |
| 269 | + pub fn with_resolver(db: DatabaseConnection) -> Self { | |
| 270 | + let http = reqwest::Client::builder() | |
| 271 | + .timeout(ACTOR_FETCH_TIMEOUT) | |
| 272 | + .build() | |
| 273 | + .expect("reqwest client builder must not fail"); | |
| 274 | + Self { | |
| 275 | + resolver: Some((db, http)), | |
| 276 | + } | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +impl Default for AlwaysAcceptVerifier { | |
| 281 | + fn default() -> Self { | |
| 282 | + Self::new() | |
| 283 | + } | |
| 284 | +} | |
| 285 | + | |
| 286 | +#[async_trait] | |
| 287 | +impl SignatureVerifier for AlwaysAcceptVerifier { | |
| 288 | + async fn verify(&self, ctx: &VerifyCtx<'_>) -> Result<(), AppError> { | |
| 289 | + tracing::warn!( | |
| 290 | + actor = %ctx.actor_ap_id, | |
| 291 | + "AlwaysAcceptVerifier in use — DO NOT enable in production", | |
| 292 | + ); | |
| 293 | + if let Some((db, http)) = &self.resolver | |
| 294 | + && let Err(e) = | |
| 295 | + fetch_and_cache_remote_actor(db, http, ctx.actor_ap_id, /* allow_private_ips */ true).await | |
| 296 | + { | |
| 297 | + // « Always accept » applies to signature verification, not | |
| 298 | + // to the cache-side-effect: a failed fetch must not 401 the | |
| 299 | + // caller. Log and let downstream services decide what to do | |
| 300 | + // with an absent row. | |
| 301 | + tracing::warn!( | |
| 302 | + actor = %ctx.actor_ap_id, | |
| 303 | + error = %e, | |
| 304 | + "AlwaysAcceptVerifier: actor fetch+cache failed (continuing)", | |
| 305 | + ); | |
| 306 | + } | |
| 307 | + Ok(()) | |
| 308 | + } | |
| 309 | +} | |
| 310 | + | |
| 311 | +/// Maximum body size accepted from a remote actor fetch. Mastodon's | |
| 312 | +/// canonical Person doc is ~2 KB; 1 MiB leaves room for icons in | |
| 313 | +/// `attachment` and large `summary` fields while bounding DoS exposure. | |
| 314 | +pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024; | |
| 315 | + | |
| 316 | +/// Per-request timeout applied to every outgoing actor fetch. Caps | |
| 317 | +/// hangs from unreachable / slow remote instances without breaking the | |
| 318 | +/// inbox handler that called the verifier. | |
| 319 | +pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); | |
| 320 | + | |
| 321 | +/// Refuse to fetch URLs whose resolved IP is loopback, private, | |
| 322 | +/// link-local, ULA, or unspecified. Prevents SSRF pivots through | |
| 323 | +/// `actor_ap_id` values like `http://169.254.169.254/...` (AWS | |
| 324 | +/// metadata) or `http://10.x.x.x/admin`. | |
| 325 | +/// | |
| 326 | +/// Caller passes `allow_private_ips=true` only in test/dev contexts | |
| 327 | +/// where the topology is local (e.g. the docker-compose testbed using | |
| 328 | +/// `*.local` aliases resolving to 172.x.x.x). | |
| 329 | +async fn ssrf_guard(url: &url::Url, allow_private_ips: bool) -> Result<(), AppError> { | |
| 330 | + if allow_private_ips { | |
| 331 | + return Ok(()); | |
| 332 | + } | |
| 333 | + let host = url | |
| 334 | + .host_str() | |
| 335 | + .ok_or_else(|| AppError::Validation("URL has no host".into()))?; | |
| 336 | + let port = url.port_or_known_default().unwrap_or(80); | |
| 337 | + let addrs = tokio::net::lookup_host((host, port)) | |
| 338 | + .await | |
| 339 | + .map_err(|e| AppError::Validation(format!("DNS resolution failed for {host}: {e}")))?; | |
| 340 | + for addr in addrs { | |
| 341 | + use std::net::IpAddr; | |
| 342 | + let ip = addr.ip(); | |
| 343 | + if ip.is_loopback() || ip.is_unspecified() { | |
| 344 | + return Err(AppError::Validation(format!( | |
| 345 | + "refusing to fetch {host}: resolves to loopback/unspecified ({ip})" | |
| 346 | + ))); | |
| 347 | + } | |
| 348 | + match ip { | |
| 349 | + IpAddr::V4(v4) => { | |
| 350 | + if v4.is_private() || v4.is_link_local() { | |
| 351 | + return Err(AppError::Validation(format!( | |
| 352 | + "refusing to fetch {host}: resolves to private/link-local ({v4})" | |
| 353 | + ))); | |
| 354 | + } | |
| 355 | + } | |
| 356 | + IpAddr::V6(v6) => { | |
| 357 | + let seg0 = v6.segments()[0]; | |
| 358 | + // ULA fc00::/7, link-local fe80::/10 | |
| 359 | + if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 { | |
| 360 | + return Err(AppError::Validation(format!( | |
| 361 | + "refusing to fetch {host}: resolves to ULA/link-local ({v6})" | |
| 362 | + ))); | |
| 363 | + } | |
| 364 | + } | |
| 365 | + } | |
| 366 | + } | |
| 367 | + Ok(()) | |
| 368 | +} | |
| 369 | + | |
| 370 | +/// Fetch a remote AP `Person` actor over HTTP and upsert the matching | |
| 371 | +/// `remote_identities` row. Idempotent: a re-fetch refreshes | |
| 372 | +/// `public_key_pem`, `inbox_url`, `shared_inbox_url`, `display_name`, | |
| 373 | +/// and `last_fetched_at`. | |
| 374 | +/// | |
| 375 | +/// Hardening: | |
| 376 | +/// - 10 s per-request timeout (via the caller's `http` client). | |
| 377 | +/// - 1 MiB body cap via Content-Length sniff + post-read assertion. | |
| 378 | +/// - SSRF guard rejecting private/loopback/link-local IPs unless | |
| 379 | +/// `allow_private_ips=true` (only set by test/dev callers). | |
| 380 | +/// | |
| 381 | +/// Network/parse errors return [`AppError::Validation`]; the caller | |
| 382 | +/// chooses whether to surface them (production verifier) or swallow | |
| 383 | +/// them (test/dev verifier). | |
| 384 | +pub async fn fetch_and_cache_remote_actor( | |
| 385 | + db: &DatabaseConnection, | |
| 386 | + http: &reqwest::Client, | |
| 387 | + actor_ap_id: &str, | |
| 388 | + allow_private_ips: bool, | |
| 389 | +) -> Result<remote_identity::Model, AppError> { | |
| 390 | + let url = url::Url::parse(actor_ap_id) | |
| 391 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?; | |
| 392 | + | |
| 393 | + ssrf_guard(&url, allow_private_ips).await?; | |
| 394 | + | |
| 395 | + let response = http | |
| 396 | + .get(actor_ap_id) | |
| 397 | + .header("Accept", "application/activity+json") | |
| 398 | + .send() | |
| 399 | + .await | |
| 400 | + .map_err(|e| AppError::Validation(format!("cannot fetch actor {actor_ap_id}: {e}")))?; | |
| 401 | + | |
| 402 | + if !response.status().is_success() { | |
| 403 | + return Err(AppError::Validation(format!( | |
| 404 | + "actor fetch returned {} for {actor_ap_id}", | |
| 405 | + response.status() | |
| 406 | + ))); | |
| 407 | + } | |
| 408 | + | |
| 409 | + // Belt: refuse early if the server advertises a body larger than | |
| 410 | + // our cap. Suspenders: re-check after the body is fully buffered, | |
| 411 | + // because Content-Length is optional and can lie. | |
| 412 | + if let Some(len) = response.content_length() | |
| 413 | + && len > MAX_ACTOR_BODY_BYTES | |
| 414 | + { | |
| 415 | + return Err(AppError::Validation(format!( | |
| 416 | + "actor {actor_ap_id} body too large: {len} > {MAX_ACTOR_BODY_BYTES}" | |
| 417 | + ))); | |
| 418 | + } | |
| 419 | + let bytes = response | |
| 420 | + .bytes() | |
| 421 | + .await | |
| 422 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} read error: {e}")))?; | |
| 423 | + if (bytes.len() as u64) > MAX_ACTOR_BODY_BYTES { | |
| 424 | + return Err(AppError::Validation(format!( | |
| 425 | + "actor {actor_ap_id} body too large: {} > {MAX_ACTOR_BODY_BYTES}", | |
| 426 | + bytes.len() | |
| 427 | + ))); | |
| 428 | + } | |
| 429 | + | |
| 430 | + let body: serde_json::Value = serde_json::from_slice(&bytes) | |
| 431 | + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?; | |
| 432 | + | |
| 433 | + let preferred_username = body | |
| 434 | + .get("preferredUsername") | |
| 435 | + .and_then(|v| v.as_str()) | |
| 436 | + .ok_or_else(|| { | |
| 437 | + AppError::Validation(format!("actor {actor_ap_id} missing preferredUsername")) | |
| 438 | + })? | |
| 439 | + .to_string(); | |
| 440 | + | |
| 441 | + let domain = url | |
| 442 | + .host_str() | |
| 443 | + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} has no host")))? | |
| 444 | + .to_string(); | |
| 445 | + | |
| 446 | + let public_key_pem = body | |
| 447 | + .get("publicKey") | |
| 448 | + .and_then(|v| v.get("publicKeyPem")) | |
| 449 | + .and_then(|v| v.as_str()) | |
| 450 | + .ok_or_else(|| { | |
| 451 | + AppError::Validation(format!( | |
| 452 | + "actor {actor_ap_id} missing publicKey.publicKeyPem" | |
| 453 | + )) | |
| 454 | + })? | |
| 455 | + .to_string(); | |
| 456 | + | |
| 457 | + let inbox_url = body | |
| 458 | + .get("inbox") | |
| 459 | + .and_then(|v| v.as_str()) | |
| 460 | + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} missing inbox")))? | |
| 461 | + .to_string(); | |
| 462 | + | |
| 463 | + let shared_inbox_url = body | |
| 464 | + .get("endpoints") | |
| 465 | + .and_then(|v| v.get("sharedInbox")) | |
| 466 | + .and_then(|v| v.as_str()) | |
| 467 | + .map(|s| s.to_string()); | |
| 468 | + | |
| 469 | + let display_name = body | |
| 470 | + .get("name") | |
| 471 | + .and_then(|v| v.as_str()) | |
| 472 | + .map(|s| s.to_string()); | |
| 473 | + | |
| 474 | + let now = Utc::now(); | |
| 475 | + let existing = remote_identity::Entity::find() | |
| 476 | + .filter(remote_identity::Column::ApId.eq(actor_ap_id)) | |
| 477 | + .one(db) | |
| 478 | + .await?; | |
| 479 | + | |
| 480 | + let model = if let Some(row) = existing { | |
| 481 | + let mut active: remote_identity::ActiveModel = row.into(); | |
| 482 | + active.public_key_pem = Set(public_key_pem); | |
| 483 | + active.inbox_url = Set(inbox_url); | |
| 484 | + active.shared_inbox_url = Set(shared_inbox_url); | |
| 485 | + active.display_name = Set(display_name); | |
| 486 | + active.raw_json = Set(Some(body)); | |
| 487 | + active.last_fetched_at = Set(now); | |
| 488 | + active.updated_at = Set(now); | |
| 489 | + active.update(db).await? | |
| 490 | + } else { | |
| 491 | + let active = remote_identity::ActiveModel { | |
| 492 | + id: Set(Uuid::new_v4()), | |
| 493 | + ap_id: Set(actor_ap_id.to_string()), | |
| 494 | + username: Set(preferred_username), | |
| 495 | + domain: Set(domain), | |
| 496 | + display_name: Set(display_name), | |
| 497 | + summary_html: Set(None), | |
| 498 | + inbox_url: Set(inbox_url), | |
| 499 | + shared_inbox_url: Set(shared_inbox_url), | |
| 500 | + public_key_pem: Set(public_key_pem), | |
| 501 | + icon_url: Set(None), | |
| 502 | + actor_type: Set("Person".to_string()), | |
| 503 | + raw_json: Set(Some(body)), | |
| 504 | + last_fetched_at: Set(now), | |
| 505 | + disabled_at: Set(None), | |
| 506 | + created_at: Set(now), | |
| 507 | + updated_at: Set(now), | |
| 508 | + }; | |
| 509 | + active.insert(db).await? | |
| 510 | + }; | |
| 511 | + Ok(model) | |
| 512 | +} | |
| 513 | + | |
| 514 | +fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> { | |
| 515 | + let url = url::Url::parse(ap_id).ok()?; | |
| 516 | + let host = url.host_str()?.to_string(); | |
| 517 | + let user = url.path_segments()?.next_back()?.to_string(); | |
| 518 | + Some((user, host)) | |
| 519 | +} | |
| 520 | + | |
| 521 | +#[cfg(test)] | |
| 522 | +mod tests { | |
| 523 | + use super::*; | |
| 524 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 525 | + | |
| 526 | + fn fresh_remote(id: Uuid, ap_id: &str) -> remote_identity::Model { | |
| 527 | + let now = Utc::now(); | |
| 528 | + remote_identity::Model { | |
| 529 | + id, | |
| 530 | + ap_id: ap_id.to_string(), | |
| 531 | + username: "alice".into(), | |
| 532 | + domain: "a.tld".into(), | |
| 533 | + display_name: Some("Alice".into()), | |
| 534 | + summary_html: None, | |
| 535 | + inbox_url: format!("{ap_id}/inbox"), | |
| 536 | + shared_inbox_url: None, | |
| 537 | + public_key_pem: "-----BEGIN PUBLIC KEY-----\n".into(), | |
| 538 | + icon_url: None, | |
| 539 | + actor_type: "Person".into(), | |
| 540 | + raw_json: None, | |
| 541 | + last_fetched_at: now, | |
| 542 | + disabled_at: None, | |
| 543 | + created_at: now, | |
| 544 | + updated_at: now, | |
| 545 | + } | |
| 546 | + } | |
| 547 | + | |
| 548 | + // ─── Pure ──────────────────────────────────────────────────────── | |
| 549 | + | |
| 550 | + #[test] | |
| 551 | + fn delete_outcome_default_is_zero() { | |
| 552 | + assert_eq!(DeleteOutcome::default().rows_disabled, 0); | |
| 553 | + assert_eq!(DeleteOutcome::default().jwts_blacklisted, 0); | |
| 554 | + } | |
| 555 | + | |
| 556 | + // ─── apply_update_actor ───────────────────────────────────────── | |
| 557 | + | |
| 558 | + #[tokio::test] | |
| 559 | + async fn apply_update_actor_upserts_when_existing() { | |
| 560 | + let id = Uuid::new_v4(); | |
| 561 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 562 | + let mut after = row.clone(); | |
| 563 | + after.display_name = Some("Alice McUpdated".into()); | |
| 564 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 565 | + .append_query_results([vec![row]]) | |
| 566 | + .append_query_results([vec![after]]) | |
| 567 | + .append_exec_results([MockExecResult { | |
| 568 | + last_insert_id: 0, | |
| 569 | + rows_affected: 1, | |
| 570 | + }]) | |
| 571 | + .into_connection(); | |
| 572 | + | |
| 573 | + let updated = FediverseSignalService::apply_update_actor( | |
| 574 | + &db, | |
| 575 | + UpdatedActor { | |
| 576 | + ap_id: "https://a.tld/users/alice".into(), | |
| 577 | + display_name: Some("Alice McUpdated".into()), | |
| 578 | + summary_html: None, | |
| 579 | + icon_url: None, | |
| 580 | + public_key_pem: None, | |
| 581 | + raw_json: None, | |
| 582 | + }, | |
| 583 | + ) | |
| 584 | + .await | |
| 585 | + .unwrap(); | |
| 586 | + assert_eq!(updated.display_name.as_deref(), Some("Alice McUpdated")); | |
| 587 | + } | |
| 588 | + | |
| 589 | + #[tokio::test] | |
| 590 | + async fn apply_update_actor_inserts_when_missing() { | |
| 591 | + let id = Uuid::new_v4(); | |
| 592 | + let new_row = fresh_remote(id, "https://a.tld/users/bob"); | |
| 593 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 594 | + // 1. SELECT existing → empty | |
| 595 | + .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 596 | + // 2. INSERT returns the row | |
| 597 | + .append_query_results([vec![new_row.clone()]]) | |
| 598 | + .append_exec_results([MockExecResult { | |
| 599 | + last_insert_id: 0, | |
| 600 | + rows_affected: 1, | |
| 601 | + }]) | |
| 602 | + .into_connection(); | |
| 603 | + | |
| 604 | + let inserted = FediverseSignalService::apply_update_actor( | |
| 605 | + &db, | |
| 606 | + UpdatedActor { | |
| 607 | + ap_id: "https://a.tld/users/bob".into(), | |
| 608 | + display_name: None, | |
| 609 | + summary_html: None, | |
| 610 | + icon_url: None, | |
| 611 | + public_key_pem: None, | |
| 612 | + raw_json: None, | |
| 613 | + }, | |
| 614 | + ) | |
| 615 | + .await | |
| 616 | + .unwrap(); | |
| 617 | + assert_eq!(inserted.ap_id, new_row.ap_id); | |
| 618 | + } | |
| 619 | + | |
| 620 | + // ─── apply_delete_actor ───────────────────────────────────────── | |
| 621 | + | |
| 622 | + #[tokio::test] | |
| 623 | + async fn apply_delete_actor_sets_disabled_at_and_returns_one() { | |
| 624 | + let id = Uuid::new_v4(); | |
| 625 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 626 | + let mut after = row.clone(); | |
| 627 | + after.disabled_at = Some(Utc::now()); | |
| 628 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 629 | + .append_query_results([vec![row]]) | |
| 630 | + .append_query_results([vec![after]]) | |
| 631 | + .append_exec_results([MockExecResult { | |
| 632 | + last_insert_id: 0, | |
| 633 | + rows_affected: 1, | |
| 634 | + }]) | |
| 635 | + .into_connection(); | |
| 636 | + let outcome = | |
| 637 | + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice") | |
| 638 | + .await | |
| 639 | + .unwrap(); | |
| 640 | + assert_eq!(outcome.rows_disabled, 1); | |
| 641 | + assert_eq!(outcome.jwts_blacklisted, 0); | |
| 642 | + } | |
| 643 | + | |
| 644 | + #[tokio::test] | |
| 645 | + async fn apply_delete_actor_returns_zero_when_no_row() { | |
| 646 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 647 | + .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 648 | + .into_connection(); | |
| 649 | + let outcome = | |
| 650 | + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/ghost") | |
| 651 | + .await | |
| 652 | + .unwrap(); | |
| 653 | + assert_eq!(outcome, DeleteOutcome::default()); | |
| 654 | + } | |
| 655 | + | |
| 656 | + #[tokio::test] | |
| 657 | + async fn apply_delete_actor_returns_zero_when_already_disabled() { | |
| 658 | + let id = Uuid::new_v4(); | |
| 659 | + let mut row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 660 | + row.disabled_at = Some(Utc::now()); | |
| 661 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 662 | + .append_query_results([vec![row]]) | |
| 663 | + .into_connection(); | |
| 664 | + let outcome = | |
| 665 | + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice") | |
| 666 | + .await | |
| 667 | + .unwrap(); | |
| 668 | + assert_eq!(outcome.rows_disabled, 0); | |
| 669 | + } | |
| 670 | + | |
| 671 | + // ─── apply_revoke_membership ──────────────────────────────────── | |
| 672 | + | |
| 673 | + #[tokio::test] | |
| 674 | + async fn apply_revoke_membership_returns_false_when_no_row() { | |
| 675 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 676 | + .append_exec_results([MockExecResult { | |
| 677 | + last_insert_id: 0, | |
| 678 | + rows_affected: 0, | |
| 679 | + }]) | |
| 680 | + .into_connection(); | |
| 681 | + let revoked = FediverseSignalService::apply_revoke_membership( | |
| 682 | + &db, | |
| 683 | + Uuid::new_v4(), | |
| 684 | + Uuid::new_v4(), | |
| 685 | + ) | |
| 686 | + .await | |
| 687 | + .unwrap(); | |
| 688 | + assert!(!revoked); | |
| 689 | + } | |
| 690 | + | |
| 691 | + #[tokio::test] | |
| 692 | + async fn apply_revoke_membership_returns_true_when_row_dropped() { | |
| 693 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 694 | + .append_exec_results([MockExecResult { | |
| 695 | + last_insert_id: 0, | |
| 696 | + rows_affected: 1, | |
| 697 | + }]) | |
| 698 | + .into_connection(); | |
| 699 | + let revoked = FediverseSignalService::apply_revoke_membership( | |
| 700 | + &db, | |
| 701 | + Uuid::new_v4(), | |
| 702 | + Uuid::new_v4(), | |
| 703 | + ) | |
| 704 | + .await | |
| 705 | + .unwrap(); | |
| 706 | + assert!(revoked); | |
| 707 | + } | |
| 708 | + | |
| 709 | + // ─── SignatureVerifier ────────────────────────────────────────── | |
| 710 | + | |
| 711 | + fn dummy_ctx<'a>(headers: &'a axum::http::HeaderMap) -> VerifyCtx<'a> { | |
| 712 | + VerifyCtx { | |
| 713 | + actor_ap_id: "https://a.tld/users/alice", | |
| 714 | + method: "post", | |
| 715 | + path: "/inbox", | |
| 716 | + headers, | |
| 717 | + body: b"", | |
| 718 | + public_key_pem: "", | |
| 719 | + } | |
| 720 | + } | |
| 721 | + | |
| 722 | + #[tokio::test] | |
| 723 | + async fn always_accept_verifier_accepts_any_input() { | |
| 724 | + let headers = axum::http::HeaderMap::new(); | |
| 725 | + AlwaysAcceptVerifier::new() | |
| 726 | + .verify(&dummy_ctx(&headers)) | |
| 727 | + .await | |
| 728 | + .expect("AlwaysAcceptVerifier never rejects"); | |
| 729 | + } | |
| 730 | + | |
| 731 | + #[tokio::test] | |
| 732 | + async fn signature_verifier_trait_object_works() { | |
| 733 | + let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier::new()); | |
| 734 | + let headers = axum::http::HeaderMap::new(); | |
| 735 | + verifier | |
| 736 | + .verify(&dummy_ctx(&headers)) | |
| 737 | + .await | |
| 738 | + .expect("trait object dispatches"); | |
| 739 | + } | |
| 740 | + | |
| 741 | + // ─── RFC 0018 — apply_delete_actor_with_iterator ──────────────── | |
| 742 | + | |
| 743 | + #[tokio::test] | |
| 744 | + async fn apply_delete_actor_with_noop_iterator_keeps_jwts_blacklisted_zero() { | |
| 745 | + use rustwarden_core::services::jwt_blacklist_service::NoopActiveTokenIterator; | |
| 746 | + let id = Uuid::new_v4(); | |
| 747 | + let row = fresh_remote(id, "https://a.tld/users/alice"); | |
| 748 | + let mut after = row.clone(); | |
| 749 | + after.disabled_at = Some(Utc::now()); | |
| 750 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 751 | + // SELECT for apply_delete_actor | |
| 752 | + .append_query_results([vec![row.clone()]]) | |
| 753 | + // SELECT issued by .update() inside apply_delete_actor | |
| 754 | + .append_query_results([vec![after.clone()]]) | |
| 755 | + // SELECT in apply_delete_actor_with_iterator (re-fetch) | |
| 756 | + .append_query_results([vec![after]]) | |
| 757 | + .append_exec_results([MockExecResult { | |
| 758 | + last_insert_id: 0, | |
| 759 | + rows_affected: 1, | |
| 760 | + }]) | |
| 761 | + .into_connection(); | |
| 762 | + let outcome = FediverseSignalService::apply_delete_actor_with_iterator( | |
| 763 | + &db, | |
| 764 | + "https://a.tld/users/alice", | |
| 765 | + &NoopActiveTokenIterator, | |
| 766 | + ) | |
| 767 | + .await | |
| 768 | + .unwrap(); | |
| 769 | + assert_eq!(outcome.rows_disabled, 1); | |
| 770 | + assert_eq!(outcome.jwts_blacklisted, 0); | |
| 771 | + } | |
| 772 | + | |
| 773 | + #[tokio::test] | |
| 774 | + async fn noop_active_token_iterator_returns_empty_list() { | |
| 775 | + use rustwarden_core::services::jwt_blacklist_service::{ | |
| 776 | + ActiveTokenIterator, NoopActiveTokenIterator, | |
| 777 | + }; | |
| 778 | + let v = NoopActiveTokenIterator | |
| 779 | + .active_tokens_for_remote(Uuid::new_v4()) | |
| 780 | + .await | |
| 781 | + .unwrap(); | |
| 782 | + assert!(v.is_empty()); | |
| 783 | + } | |
| 784 | + | |
| 785 | + // ─── fetch_and_cache_remote_actor (F2 hardening) ──────────────── | |
| 786 | + | |
| 787 | + /// Build a syntactically valid AP Person doc with the given overrides. | |
| 788 | + /// `extra` is merged at the top level — pass `null` for « no extra ». | |
| 789 | + fn ap_person_json( | |
| 790 | + actor_url: &str, | |
| 791 | + username: &str, | |
| 792 | + with_public_key: bool, | |
| 793 | + with_inbox: bool, | |
| 794 | + ) -> serde_json::Value { | |
| 795 | + let mut doc = serde_json::json!({ | |
| 796 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 797 | + "type": "Person", | |
| 798 | + "id": actor_url, | |
| 799 | + "preferredUsername": username, | |
| 800 | + "name": username, | |
| 801 | + }); | |
| 802 | + if with_public_key { | |
| 803 | + doc["publicKey"] = serde_json::json!({ | |
| 804 | + "id": format!("{actor_url}#main-key"), | |
| 805 | + "owner": actor_url, | |
| 806 | + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----\n", | |
| 807 | + }); | |
| 808 | + } | |
| 809 | + if with_inbox { | |
| 810 | + doc["inbox"] = serde_json::json!(format!("{actor_url}/inbox")); | |
| 811 | + } | |
| 812 | + doc | |
| 813 | + } | |
| 814 | + | |
| 815 | + fn testbed_http_client() -> reqwest::Client { | |
| 816 | + reqwest::Client::builder() | |
| 817 | + .timeout(std::time::Duration::from_secs(2)) | |
| 818 | + .build() | |
| 819 | + .unwrap() | |
| 820 | + } | |
| 821 | + | |
| 822 | + #[tokio::test] | |
| 823 | + async fn fetch_inserts_new_remote_identity_on_first_sight() { | |
| 824 | + use wiremock::matchers::{method, path}; | |
| 825 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 826 | + | |
| 827 | + let server = MockServer::start().await; | |
| 828 | + let actor_url = format!("{}/users/alice", server.uri()); | |
| 829 | + Mock::given(method("GET")) | |
| 830 | + .and(path("/users/alice")) | |
| 831 | + .respond_with( | |
| 832 | + ResponseTemplate::new(200) | |
| 833 | + .set_body_json(ap_person_json(&actor_url, "alice", true, true)), | |
| 834 | + ) | |
| 835 | + .mount(&server) | |
| 836 | + .await; | |
| 837 | + | |
| 838 | + let inserted = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 839 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 840 | + .append_query_results([Vec::<remote_identity::Model>::new()]) // SELECT empty | |
| 841 | + .append_query_results([vec![inserted.clone()]]) // INSERT RETURNING | |
| 842 | + .append_exec_results([MockExecResult { | |
| 843 | + last_insert_id: 0, | |
| 844 | + rows_affected: 1, | |
| 845 | + }]) | |
| 846 | + .into_connection(); | |
| 847 | + | |
| 848 | + let model = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 849 | + .await | |
| 850 | + .expect("happy path INSERT must succeed"); | |
| 851 | + assert_eq!(model.ap_id, inserted.ap_id); | |
| 852 | + } | |
| 853 | + | |
| 854 | + #[tokio::test] | |
| 855 | + async fn fetch_updates_existing_remote_identity_on_refetch() { | |
| 856 | + use wiremock::matchers::{method, path}; | |
| 857 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 858 | + | |
| 859 | + let server = MockServer::start().await; | |
| 860 | + let actor_url = format!("{}/users/bob", server.uri()); | |
| 861 | + Mock::given(method("GET")) | |
| 862 | + .and(path("/users/bob")) | |
| 863 | + .respond_with( | |
| 864 | + ResponseTemplate::new(200) | |
| 865 | + .set_body_json(ap_person_json(&actor_url, "bob", true, true)), | |
| 866 | + ) | |
| 867 | + .mount(&server) | |
| 868 | + .await; | |
| 869 | + | |
| 870 | + let existing = fresh_remote(Uuid::new_v4(), &actor_url); | |
| 871 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 872 | + .append_query_results([vec![existing.clone()]]) // SELECT hit | |
| 873 | + .append_query_results([vec![existing.clone()]]) // UPDATE RETURNING | |
| 874 | + .append_exec_results([MockExecResult { | |
| 875 | + last_insert_id: 0, | |
| 876 | + rows_affected: 1, | |
| 877 | + }]) | |
| 878 | + .into_connection(); | |
| 879 | + | |
| 880 | + fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 881 | + .await | |
| 882 | + .expect("happy path UPDATE must succeed"); | |
| 883 | + } | |
| 884 | + | |
| 885 | + #[tokio::test] | |
| 886 | + async fn fetch_returns_error_on_http_404() { | |
| 887 | + use wiremock::matchers::{method, path}; | |
| 888 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 889 | + | |
| 890 | + let server = MockServer::start().await; | |
| 891 | + let actor_url = format!("{}/users/ghost", server.uri()); | |
| 892 | + Mock::given(method("GET")) | |
| 893 | + .and(path("/users/ghost")) | |
| 894 | + .respond_with(ResponseTemplate::new(404)) | |
| 895 | + .mount(&server) | |
| 896 | + .await; | |
| 897 | + | |
| 898 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 899 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 900 | + .await | |
| 901 | + .expect_err("404 must be surfaced as Validation"); | |
| 902 | + assert!(format!("{err}").contains("404"), "got: {err}"); | |
| 903 | + } | |
| 904 | + | |
| 905 | + #[tokio::test] | |
| 906 | + async fn fetch_returns_error_on_malformed_json() { | |
| 907 | + use wiremock::matchers::{method, path}; | |
| 908 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 909 | + | |
| 910 | + let server = MockServer::start().await; | |
| 911 | + let actor_url = format!("{}/users/junk", server.uri()); | |
| 912 | + Mock::given(method("GET")) | |
| 913 | + .and(path("/users/junk")) | |
| 914 | + .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all")) | |
| 915 | + .mount(&server) | |
| 916 | + .await; | |
| 917 | + | |
| 918 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 919 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 920 | + .await | |
| 921 | + .expect_err("malformed JSON must be rejected"); | |
| 922 | + assert!(format!("{err}").contains("not valid JSON"), "got: {err}"); | |
| 923 | + } | |
| 924 | + | |
| 925 | + #[tokio::test] | |
| 926 | + async fn fetch_returns_error_when_public_key_missing() { | |
| 927 | + use wiremock::matchers::{method, path}; | |
| 928 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 929 | + | |
| 930 | + let server = MockServer::start().await; | |
| 931 | + let actor_url = format!("{}/users/nokey", server.uri()); | |
| 932 | + Mock::given(method("GET")) | |
| 933 | + .and(path("/users/nokey")) | |
| 934 | + .respond_with( | |
| 935 | + ResponseTemplate::new(200) | |
| 936 | + .set_body_json(ap_person_json(&actor_url, "nokey", false, true)), | |
| 937 | + ) | |
| 938 | + .mount(&server) | |
| 939 | + .await; | |
| 940 | + | |
| 941 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 942 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 943 | + .await | |
| 944 | + .expect_err("missing publicKey must be rejected"); | |
| 945 | + assert!(format!("{err}").contains("publicKey"), "got: {err}"); | |
| 946 | + } | |
| 947 | + | |
| 948 | + #[tokio::test] | |
| 949 | + async fn fetch_returns_error_when_inbox_missing() { | |
| 950 | + use wiremock::matchers::{method, path}; | |
| 951 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 952 | + | |
| 953 | + let server = MockServer::start().await; | |
| 954 | + let actor_url = format!("{}/users/noinbox", server.uri()); | |
| 955 | + Mock::given(method("GET")) | |
| 956 | + .and(path("/users/noinbox")) | |
| 957 | + .respond_with( | |
| 958 | + ResponseTemplate::new(200) | |
| 959 | + .set_body_json(ap_person_json(&actor_url, "noinbox", true, false)), | |
| 960 | + ) | |
| 961 | + .mount(&server) | |
| 962 | + .await; | |
| 963 | + | |
| 964 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 965 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 966 | + .await | |
| 967 | + .expect_err("missing inbox must be rejected"); | |
| 968 | + assert!(format!("{err}").contains("inbox"), "got: {err}"); | |
| 969 | + } | |
| 970 | + | |
| 971 | + #[tokio::test] | |
| 972 | + async fn fetch_returns_error_on_invalid_url() { | |
| 973 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 974 | + let err = | |
| 975 | + fetch_and_cache_remote_actor(&db, &testbed_http_client(), "not a valid url", true) | |
| 976 | + .await | |
| 977 | + .expect_err("malformed URL must be rejected before HTTP"); | |
| 978 | + assert!(format!("{err}").contains("not a valid URL"), "got: {err}"); | |
| 979 | + } | |
| 980 | + | |
| 981 | + #[tokio::test] | |
| 982 | + async fn fetch_returns_error_when_body_exceeds_cap() { | |
| 983 | + use wiremock::matchers::{method, path}; | |
| 984 | + use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 985 | + | |
| 986 | + let server = MockServer::start().await; | |
| 987 | + let actor_url = format!("{}/users/huge", server.uri()); | |
| 988 | + // Send a payload just over our cap. wiremock sets a truthful | |
| 989 | + // Content-Length, so the pre-flight check trips before we | |
| 990 | + // allocate the full body in our process. | |
| 991 | + let payload = vec![b'x'; (MAX_ACTOR_BODY_BYTES as usize) + 1]; | |
| 992 | + Mock::given(method("GET")) | |
| 993 | + .and(path("/users/huge")) | |
| 994 | + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload)) | |
| 995 | + .mount(&server) | |
| 996 | + .await; | |
| 997 | + | |
| 998 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 999 | + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true) | |
| 1000 | + .await | |
| 1001 | + .expect_err("oversized body must be rejected"); | |
| 1002 | + assert!(format!("{err}").contains("too large"), "got: {err}"); | |
| 1003 | + } | |
| 1004 | + | |
| 1005 | + #[tokio::test] | |
| 1006 | + async fn ssrf_guard_rejects_loopback_when_strict() { | |
| 1007 | + // Use a known-loopback URL (wiremock binds 127.0.0.1, so the | |
| 1008 | + // server starts but the guard must reject before HTTP fires). | |
| 1009 | + use wiremock::MockServer; | |
| 1010 | + let server = MockServer::start().await; | |
| 1011 | + let actor_url = format!("{}/users/anything", server.uri()); | |
| 1012 | + | |
| 1013 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 1014 | + let err = fetch_and_cache_remote_actor( | |
| 1015 | + &db, | |
| 1016 | + &testbed_http_client(), | |
| 1017 | + &actor_url, | |
| 1018 | + /* allow_private_ips */ false, | |
| 1019 | + ) | |
| 1020 | + .await | |
| 1021 | + .expect_err("loopback must be refused when allow_private_ips=false"); | |
| 1022 | + let msg = format!("{err}"); | |
| 1023 | + assert!( | |
| 1024 | + msg.contains("loopback") || msg.contains("private") || msg.contains("link-local"), | |
| 1025 | + "expected SSRF guard rejection, got: {err}" | |
| 1026 | + ); | |
| 1027 | + } | |
| 1028 | +} |
A
crates/rustwarden-fediverse/src/services/fediverse_trust_service.rs
+449
-0
@@ -0,0 +1,449 @@
| 1 | +//! Federation trust model service (RFC 0004). | |
| 2 | +//! | |
| 3 | +//! Implements the runtime-configurable trust check applied **before** any | |
| 4 | +//! signature verification or fetch towards a remote Fediverse domain. The | |
| 5 | +//! mode (allowlist / open + blocklist), the global enabled flag, and the | |
| 6 | +//! interop scope are stored in `app_settings`; the lists themselves live | |
| 7 | +//! in `fediverse_trusted_domains` and `fediverse_blocked_domains`. | |
| 8 | +//! | |
| 9 | +//! Gated by `experimental-fediverse`. | |
| 10 | + | |
| 11 | +use chrono::Utc; | |
| 12 | +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; | |
| 13 | +use uuid::Uuid; | |
| 14 | + | |
| 15 | +use rustwarden_core::error::AppError; | |
| 16 | +use crate::models::{fediverse_blocked_domain, fediverse_trusted_domain}; | |
| 17 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 18 | + | |
| 19 | +/// Available federation trust modes (`app_settings.fediverse_trust_mode`). | |
| 20 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 21 | +pub enum TrustMode { | |
| 22 | + /// Zero-trust default: only domains explicitly added to the allowlist | |
| 23 | + /// are accepted. | |
| 24 | + Allowlist, | |
| 25 | + /// Mastodon-style: any domain is accepted unless present in the | |
| 26 | + /// blocklist. | |
| 27 | + OpenWithBlocklist, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl TrustMode { | |
| 31 | + /// Parse the wire form stored in `app_settings`. | |
| 32 | + pub fn parse(raw: &str) -> Self { | |
| 33 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 34 | + "open_with_blocklist" | "open" | "blocklist" => Self::OpenWithBlocklist, | |
| 35 | + _ => Self::Allowlist, | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Available interop scopes (`app_settings.fediverse_interop_scope`). | |
| 41 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 42 | +pub enum InteropScope { | |
| 43 | + /// Authentication and invitations restricted to Rustwarden instances. | |
| 44 | + RustwardenOnly, | |
| 45 | + /// Auth restricted to Rustwarden, but profile reads accepted from any | |
| 46 | + /// AP-compatible instance (Mastodon discovery use-case). | |
| 47 | + RustwardenPlusUniversalRead, | |
| 48 | + /// Anything goes (advanced). | |
| 49 | + Open, | |
| 50 | +} | |
| 51 | + | |
| 52 | +impl InteropScope { | |
| 53 | + /// Parse the wire form stored in `app_settings`. | |
| 54 | + pub fn parse(raw: &str) -> Self { | |
| 55 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 56 | + "rustwarden_plus_universal_read" | "universal_read" => { | |
| 57 | + Self::RustwardenPlusUniversalRead | |
| 58 | + } | |
| 59 | + "open" => Self::Open, | |
| 60 | + _ => Self::RustwardenOnly, | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// Severity level of a [`fediverse_blocked_domain`] entry. | |
| 66 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 67 | +pub enum BlockSeverity { | |
| 68 | + /// Reject all activities and auth requests from the domain. | |
| 69 | + Suspend, | |
| 70 | + /// Accept but flag activities silently. | |
| 71 | + Silence, | |
| 72 | + /// Reject auth specifically (allow read-only profile lookups). | |
| 73 | + RejectAuth, | |
| 74 | +} | |
| 75 | + | |
| 76 | +impl BlockSeverity { | |
| 77 | + /// Parse the wire form. Falls back to [`BlockSeverity::Suspend`] for | |
| 78 | + /// unknown values (conservative default). | |
| 79 | + pub fn parse(raw: &str) -> Self { | |
| 80 | + match raw.trim().to_ascii_lowercase().as_str() { | |
| 81 | + "silence" => Self::Silence, | |
| 82 | + "reject_auth" => Self::RejectAuth, | |
| 83 | + _ => Self::Suspend, | |
| 84 | + } | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Normalise a domain to its canonical comparison form: trim whitespace, | |
| 89 | +/// lowercase, strip a single leading `www.` segment. | |
| 90 | +pub fn normalize_domain(raw: &str) -> String { | |
| 91 | + let trimmed = raw.trim().to_ascii_lowercase(); | |
| 92 | + trimmed | |
| 93 | + .strip_prefix("www.") | |
| 94 | + .map(|s| s.to_string()) | |
| 95 | + .unwrap_or(trimmed) | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Service exposing the central `is_domain_allowed` gate plus admin CRUD | |
| 99 | +/// over the trust/block lists. | |
| 100 | +#[derive(Clone)] | |
| 101 | +pub struct FediverseTrustService { | |
| 102 | + db: DatabaseConnection, | |
| 103 | +} | |
| 104 | + | |
| 105 | +impl FediverseTrustService { | |
| 106 | + /// Build a service bound to the given DB connection. | |
| 107 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 108 | + Self { db } | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Read `app_settings.fediverse_enabled` (default `false`). | |
| 112 | + pub async fn is_enabled(&self) -> Result<bool, AppError> { | |
| 113 | + AppSettingsService::get_bool(&self.db, "fediverse_enabled", false).await | |
| 114 | + } | |
| 115 | + | |
| 116 | + /// Read `app_settings.fediverse_trust_mode` (default | |
| 117 | + /// [`TrustMode::Allowlist`]). | |
| 118 | + pub async fn trust_mode(&self) -> Result<TrustMode, AppError> { | |
| 119 | + let raw = AppSettingsService::get_setting(&self.db, "fediverse_trust_mode").await?; | |
| 120 | + Ok(raw.map(|m| TrustMode::parse(&m.value)).unwrap_or(TrustMode::Allowlist)) | |
| 121 | + } | |
| 122 | + | |
| 123 | + /// Read `app_settings.fediverse_interop_scope` (default | |
| 124 | + /// [`InteropScope::RustwardenOnly`]). | |
| 125 | + pub async fn interop_scope(&self) -> Result<InteropScope, AppError> { | |
| 126 | + let raw = AppSettingsService::get_setting(&self.db, "fediverse_interop_scope").await?; | |
| 127 | + Ok(raw | |
| 128 | + .map(|m| InteropScope::parse(&m.value)) | |
| 129 | + .unwrap_or(InteropScope::RustwardenOnly)) | |
| 130 | + } | |
| 131 | + | |
| 132 | + /// Central trust check applied **before** any signature verification or | |
| 133 | + /// fetch towards a remote domain. | |
| 134 | + /// | |
| 135 | + /// Returns `false` (deny) when: | |
| 136 | + /// - `app_settings.fediverse_enabled = false` | |
| 137 | + /// - mode = [`TrustMode::Allowlist`] and `domain` is **not** in | |
| 138 | + /// `fediverse_trusted_domains` | |
| 139 | + /// - mode = [`TrustMode::OpenWithBlocklist`] and `domain` is in | |
| 140 | + /// `fediverse_blocked_domains` | |
| 141 | + /// | |
| 142 | + /// `domain` is normalised via [`normalize_domain`] before lookup. | |
| 143 | + pub async fn is_domain_allowed(&self, domain: &str) -> Result<bool, AppError> { | |
| 144 | + if !self.is_enabled().await? { | |
| 145 | + return Ok(false); | |
| 146 | + } | |
| 147 | + let normalized = normalize_domain(domain); | |
| 148 | + match self.trust_mode().await? { | |
| 149 | + TrustMode::Allowlist => self.is_in_trusted(&normalized).await, | |
| 150 | + TrustMode::OpenWithBlocklist => Ok(!self.is_in_blocked(&normalized).await?), | |
| 151 | + } | |
| 152 | + } | |
| 153 | + | |
| 154 | + /// F8 / RFC 0027 enforcement helper. Returns `Ok(())` when the | |
| 155 | + /// origin domain of an inbound activity is allowed; otherwise | |
| 156 | + /// returns [`AppError::Validation`] with a message safe to surface | |
| 157 | + /// (no leakage of internal state beyond the domain name). | |
| 158 | + /// | |
| 159 | + /// Intended call sites: top of every protocolar inbox / route that | |
| 160 | + /// accepts data from outside. Cheap (2 SELECTs at most) — caches | |
| 161 | + /// can be added later if needed. | |
| 162 | + pub async fn assert_domain_allowed(&self, domain: &str) -> Result<(), AppError> { | |
| 163 | + if self.is_domain_allowed(domain).await? { | |
| 164 | + Ok(()) | |
| 165 | + } else { | |
| 166 | + Err(AppError::Validation(format!( | |
| 167 | + "domain `{}` is not allowed by this server's trust policy", | |
| 168 | + normalize_domain(domain) | |
| 169 | + ))) | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> { | |
| 174 | + Ok(fediverse_trusted_domain::Entity::find() | |
| 175 | + .filter(fediverse_trusted_domain::Column::Domain.eq(domain)) | |
| 176 | + .one(&self.db) | |
| 177 | + .await? | |
| 178 | + .is_some()) | |
| 179 | + } | |
| 180 | + | |
| 181 | + async fn is_in_blocked(&self, domain: &str) -> Result<bool, AppError> { | |
| 182 | + Ok(fediverse_blocked_domain::Entity::find() | |
| 183 | + .filter(fediverse_blocked_domain::Column::Domain.eq(domain)) | |
| 184 | + .one(&self.db) | |
| 185 | + .await? | |
| 186 | + .is_some()) | |
| 187 | + } | |
| 188 | + | |
| 189 | + /// Insert a new entry in the trusted-domains allowlist. | |
| 190 | + pub async fn add_trusted_domain( | |
| 191 | + &self, | |
| 192 | + domain: &str, | |
| 193 | + note: Option<&str>, | |
| 194 | + added_by: Option<Uuid>, | |
| 195 | + ) -> Result<fediverse_trusted_domain::Model, AppError> { | |
| 196 | + let now = Utc::now(); | |
| 197 | + let model = fediverse_trusted_domain::ActiveModel { | |
| 198 | + id: Set(Uuid::new_v4()), | |
| 199 | + domain: Set(normalize_domain(domain)), | |
| 200 | + note: Set(note.map(str::to_string)), | |
| 201 | + added_by_user_id: Set(added_by), | |
| 202 | + created_at: Set(now), | |
| 203 | + }; | |
| 204 | + Ok(model.insert(&self.db).await?) | |
| 205 | + } | |
| 206 | + | |
| 207 | + /// Remove an entry from the trusted-domains allowlist. | |
| 208 | + /// Returns `true` if a row was deleted, `false` if no match was found. | |
| 209 | + pub async fn remove_trusted_domain(&self, domain: &str) -> Result<bool, AppError> { | |
| 210 | + let normalized = normalize_domain(domain); | |
| 211 | + let result = fediverse_trusted_domain::Entity::delete_many() | |
| 212 | + .filter(fediverse_trusted_domain::Column::Domain.eq(normalized)) | |
| 213 | + .exec(&self.db) | |
| 214 | + .await?; | |
| 215 | + Ok(result.rows_affected > 0) | |
| 216 | + } | |
| 217 | + | |
| 218 | + /// List all entries in the trusted-domains allowlist. | |
| 219 | + pub async fn list_trusted_domains( | |
| 220 | + &self, | |
| 221 | + ) -> Result<Vec<fediverse_trusted_domain::Model>, AppError> { | |
| 222 | + Ok(fediverse_trusted_domain::Entity::find() | |
| 223 | + .all(&self.db) | |
| 224 | + .await?) | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// Insert a new entry in the blocked-domains blocklist. | |
| 228 | + pub async fn add_blocked_domain( | |
| 229 | + &self, | |
| 230 | + domain: &str, | |
| 231 | + severity: BlockSeverity, | |
| 232 | + reason: Option<&str>, | |
| 233 | + added_by: Option<Uuid>, | |
| 234 | + ) -> Result<fediverse_blocked_domain::Model, AppError> { | |
| 235 | + let now = Utc::now(); | |
| 236 | + let severity_str = match severity { | |
| 237 | + BlockSeverity::Suspend => "suspend", | |
| 238 | + BlockSeverity::Silence => "silence", | |
| 239 | + BlockSeverity::RejectAuth => "reject_auth", | |
| 240 | + }; | |
| 241 | + let model = fediverse_blocked_domain::ActiveModel { | |
| 242 | + id: Set(Uuid::new_v4()), | |
| 243 | + domain: Set(normalize_domain(domain)), | |
| 244 | + severity: Set(severity_str.to_string()), | |
| 245 | + reason: Set(reason.map(str::to_string)), | |
| 246 | + added_by_user_id: Set(added_by), | |
| 247 | + created_at: Set(now), | |
| 248 | + }; | |
| 249 | + Ok(model.insert(&self.db).await?) | |
| 250 | + } | |
| 251 | + | |
| 252 | + /// Remove an entry from the blocked-domains blocklist. | |
| 253 | + /// Returns `true` if a row was deleted. | |
| 254 | + pub async fn remove_blocked_domain(&self, domain: &str) -> Result<bool, AppError> { | |
| 255 | + let normalized = normalize_domain(domain); | |
| 256 | + let result = fediverse_blocked_domain::Entity::delete_many() | |
| 257 | + .filter(fediverse_blocked_domain::Column::Domain.eq(normalized)) | |
| 258 | + .exec(&self.db) | |
| 259 | + .await?; | |
| 260 | + Ok(result.rows_affected > 0) | |
| 261 | + } | |
| 262 | + | |
| 263 | + /// List all entries in the blocked-domains blocklist. | |
| 264 | + pub async fn list_blocked_domains( | |
| 265 | + &self, | |
| 266 | + ) -> Result<Vec<fediverse_blocked_domain::Model>, AppError> { | |
| 267 | + Ok(fediverse_blocked_domain::Entity::find() | |
| 268 | + .all(&self.db) | |
| 269 | + .await?) | |
| 270 | + } | |
| 271 | +} | |
| 272 | + | |
| 273 | +#[cfg(test)] | |
| 274 | +mod tests { | |
| 275 | + use super::*; | |
| 276 | + use rustwarden_core::models::app_settings; | |
| 277 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 278 | + | |
| 279 | + fn enabled_setting(value: &str) -> app_settings::Model { | |
| 280 | + let now = Utc::now(); | |
| 281 | + app_settings::Model { | |
| 282 | + id: Uuid::new_v4(), | |
| 283 | + key: "fediverse_enabled".to_string(), | |
| 284 | + value: value.to_string(), | |
| 285 | + description: None, | |
| 286 | + updated_by: None, | |
| 287 | + updated_at: now, | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + fn mode_setting(value: &str) -> app_settings::Model { | |
| 292 | + let now = Utc::now(); | |
| 293 | + app_settings::Model { | |
| 294 | + id: Uuid::new_v4(), | |
| 295 | + key: "fediverse_trust_mode".to_string(), | |
| 296 | + value: value.to_string(), | |
| 297 | + description: None, | |
| 298 | + updated_by: None, | |
| 299 | + updated_at: now, | |
| 300 | + } | |
| 301 | + } | |
| 302 | + | |
| 303 | + fn trusted_row(domain: &str) -> fediverse_trusted_domain::Model { | |
| 304 | + let now = Utc::now(); | |
| 305 | + fediverse_trusted_domain::Model { | |
| 306 | + id: Uuid::new_v4(), | |
| 307 | + domain: domain.to_string(), | |
| 308 | + note: None, | |
| 309 | + added_by_user_id: None, | |
| 310 | + created_at: now, | |
| 311 | + } | |
| 312 | + } | |
| 313 | + | |
| 314 | + fn blocked_row(domain: &str) -> fediverse_blocked_domain::Model { | |
| 315 | + let now = Utc::now(); | |
| 316 | + fediverse_blocked_domain::Model { | |
| 317 | + id: Uuid::new_v4(), | |
| 318 | + domain: domain.to_string(), | |
| 319 | + severity: "suspend".to_string(), | |
| 320 | + reason: None, | |
| 321 | + added_by_user_id: None, | |
| 322 | + created_at: now, | |
| 323 | + } | |
| 324 | + } | |
| 325 | + | |
| 326 | + // ─── Pure helpers ──────────────────────────────────────────────── | |
| 327 | + | |
| 328 | + #[test] | |
| 329 | + fn trust_mode_parses_known_values() { | |
| 330 | + assert_eq!(TrustMode::parse("allowlist"), TrustMode::Allowlist); | |
| 331 | + assert_eq!( | |
| 332 | + TrustMode::parse("open_with_blocklist"), | |
| 333 | + TrustMode::OpenWithBlocklist | |
| 334 | + ); | |
| 335 | + assert_eq!(TrustMode::parse("open"), TrustMode::OpenWithBlocklist); | |
| 336 | + } | |
| 337 | + | |
| 338 | + #[test] | |
| 339 | + fn trust_mode_falls_back_to_allowlist_for_unknown() { | |
| 340 | + assert_eq!(TrustMode::parse("garbage"), TrustMode::Allowlist); | |
| 341 | + assert_eq!(TrustMode::parse(""), TrustMode::Allowlist); | |
| 342 | + } | |
| 343 | + | |
| 344 | + #[test] | |
| 345 | + fn interop_scope_parses_known_values() { | |
| 346 | + assert_eq!( | |
| 347 | + InteropScope::parse("rustwarden_only"), | |
| 348 | + InteropScope::RustwardenOnly | |
| 349 | + ); | |
| 350 | + assert_eq!( | |
| 351 | + InteropScope::parse("rustwarden_plus_universal_read"), | |
| 352 | + InteropScope::RustwardenPlusUniversalRead | |
| 353 | + ); | |
| 354 | + assert_eq!(InteropScope::parse("open"), InteropScope::Open); | |
| 355 | + } | |
| 356 | + | |
| 357 | + #[test] | |
| 358 | + fn block_severity_parses_known_values() { | |
| 359 | + assert_eq!(BlockSeverity::parse("suspend"), BlockSeverity::Suspend); | |
| 360 | + assert_eq!(BlockSeverity::parse("silence"), BlockSeverity::Silence); | |
| 361 | + assert_eq!(BlockSeverity::parse("reject_auth"), BlockSeverity::RejectAuth); | |
| 362 | + } | |
| 363 | + | |
| 364 | + #[test] | |
| 365 | + fn block_severity_falls_back_to_suspend_for_unknown() { | |
| 366 | + assert_eq!(BlockSeverity::parse("nope"), BlockSeverity::Suspend); | |
| 367 | + } | |
| 368 | + | |
| 369 | + #[test] | |
| 370 | + fn normalize_domain_lowercases_and_strips_www() { | |
| 371 | + assert_eq!(normalize_domain("Example.ORG"), "example.org"); | |
| 372 | + assert_eq!(normalize_domain("www.example.org"), "example.org"); | |
| 373 | + assert_eq!(normalize_domain(" www.Mastodon.SOCIAL "), "mastodon.social"); | |
| 374 | + assert_eq!(normalize_domain("example.org"), "example.org"); | |
| 375 | + } | |
| 376 | + | |
| 377 | + // ─── DB-mocked tests ──────────────────────────────────────────── | |
| 378 | + | |
| 379 | + #[tokio::test] | |
| 380 | + async fn is_domain_allowed_returns_false_when_disabled() { | |
| 381 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 382 | + .append_query_results([vec![enabled_setting("false")]]) | |
| 383 | + .into_connection(); | |
| 384 | + let svc = FediverseTrustService::new(db); | |
| 385 | + assert!(!svc.is_domain_allowed("trusted.tld").await.unwrap()); | |
| 386 | + } | |
| 387 | + | |
| 388 | + #[tokio::test] | |
| 389 | + async fn is_domain_allowed_returns_true_when_in_allowlist() { | |
| 390 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 391 | + .append_query_results([vec![enabled_setting("true")]]) | |
| 392 | + .append_query_results([vec![mode_setting("allowlist")]]) | |
| 393 | + .append_query_results([vec![trusted_row("partner.tld")]]) | |
| 394 | + .into_connection(); | |
| 395 | + let svc = FediverseTrustService::new(db); | |
| 396 | + assert!(svc.is_domain_allowed("Partner.TLD").await.unwrap()); | |
| 397 | + } | |
| 398 | + | |
| 399 | + #[tokio::test] | |
| 400 | + async fn is_domain_allowed_returns_false_when_not_in_allowlist() { | |
| 401 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 402 | + .append_query_results([vec![enabled_setting("true")]]) | |
| 403 | + .append_query_results([vec![mode_setting("allowlist")]]) | |
| 404 | + .append_query_results([Vec::<fediverse_trusted_domain::Model>::new()]) | |
| 405 | + .into_connection(); | |
| 406 | + let svc = FediverseTrustService::new(db); | |
| 407 | + assert!(!svc.is_domain_allowed("stranger.tld").await.unwrap()); | |
| 408 | + } | |
| 409 | + | |
| 410 | + #[tokio::test] | |
| 411 | + async fn is_domain_allowed_returns_true_in_open_mode_when_not_blocked() { | |
| 412 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 413 | + .append_query_results([vec![enabled_setting("true")]]) | |
| 414 | + .append_query_results([vec![mode_setting("open_with_blocklist")]]) | |
| 415 | + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()]) | |
| 416 | + .into_connection(); | |
| 417 | + let svc = FediverseTrustService::new(db); | |
| 418 | + assert!(svc.is_domain_allowed("anybody.tld").await.unwrap()); | |
| 419 | + } | |
| 420 | + | |
| 421 | + #[tokio::test] | |
| 422 | + async fn is_domain_allowed_returns_false_in_open_mode_when_blocked() { | |
| 423 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 424 | + .append_query_results([vec![enabled_setting("true")]]) | |
| 425 | + .append_query_results([vec![mode_setting("open_with_blocklist")]]) | |
| 426 | + .append_query_results([vec![blocked_row("evil.tld")]]) | |
| 427 | + .into_connection(); | |
| 428 | + let svc = FediverseTrustService::new(db); | |
| 429 | + assert!(!svc.is_domain_allowed("evil.tld").await.unwrap()); | |
| 430 | + } | |
| 431 | + | |
| 432 | + #[tokio::test] | |
| 433 | + async fn add_trusted_domain_inserts_record() { | |
| 434 | + let row = trusted_row("partner.tld"); | |
| 435 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 436 | + .append_query_results([vec![row.clone()]]) | |
| 437 | + .append_exec_results([MockExecResult { | |
| 438 | + last_insert_id: 0, | |
| 439 | + rows_affected: 1, | |
| 440 | + }]) | |
| 441 | + .into_connection(); | |
| 442 | + let svc = FediverseTrustService::new(db); | |
| 443 | + let inserted = svc | |
| 444 | + .add_trusted_domain("Partner.TLD", Some("partnership"), None) | |
| 445 | + .await | |
| 446 | + .unwrap(); | |
| 447 | + assert_eq!(inserted.domain, "partner.tld"); | |
| 448 | + } | |
| 449 | +} |
A
crates/rustwarden-fediverse/src/services/mod.rs
+20
-0
@@ -0,0 +1,20 @@
| 1 | +//! Federation business-logic services. | |
| 2 | + | |
| 3 | +/// Local actor RSA key generation and encrypted storage (RFC 0005). | |
| 4 | +pub mod actor_key_service; | |
| 5 | +/// AP-native authentication flow service (RFC 0006). | |
| 6 | +pub mod fediverse_auth_service; | |
| 7 | +/// Cross-instance team-membership invitations (RFC 0009). | |
| 8 | +pub mod fediverse_invite_service; | |
| 9 | +/// Outbound ActivityPub delivery queue + worker (RFC 0011). | |
| 10 | +pub mod fediverse_outbound_service; | |
| 11 | +/// Fediverse lifecycle signal handlers (RFC 0010). | |
| 12 | +pub mod fediverse_signal_service; | |
| 13 | +/// Federation trust model (RFC 0004). | |
| 14 | +pub mod fediverse_trust_service; | |
| 15 | +/// Remote ActivityPub identity store (RFC 0003). | |
| 16 | +pub mod remote_identity_service; | |
| 17 | +/// SeaORM-backed | |
| 18 | +/// [`RemoteIdentityRevocationChecker`](rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker) | |
| 19 | +/// implementation injected at federation mount time. | |
| 20 | +pub mod revocation; |
A
crates/rustwarden-fediverse/src/services/remote_identity_service.rs
+435
-0
@@ -0,0 +1,435 @@
| 1 | +//! Remote ActivityPub identity service (RFC 0003). | |
| 2 | +//! | |
| 3 | +//! Caches remote actors in the `remote_identities` table and exposes | |
| 4 | +//! `resolve_handle` which performs WebFinger + actor fetch when the cache | |
| 5 | +//! is stale or empty. | |
| 6 | +//! | |
| 7 | +//! Gated by `experimental-fediverse`. | |
| 8 | + | |
| 9 | +use std::sync::Arc; | |
| 10 | +use std::time::Duration; | |
| 11 | + | |
| 12 | +use chrono::{DateTime, Utc}; | |
| 13 | +use sea_orm::DatabaseConnection; | |
| 14 | +use uuid::Uuid; | |
| 15 | + | |
| 16 | +use crate::config::FediverseConfig; | |
| 17 | +use rustwarden_core::error::AppError; | |
| 18 | +use crate::error::FediverseError; | |
| 19 | +use crate::fetcher::RemoteFetcher; | |
| 20 | +use crate::types::PersonJson; | |
| 21 | +use crate::models::remote_identity; | |
| 22 | + | |
| 23 | +/// Service orchestrating remote actor resolution and caching. | |
| 24 | +/// | |
| 25 | +/// Constructed by consumers (RFCs 0008+ for invitations / Identity | |
| 26 | +/// abstraction will inject one). Cheap to clone — the DB handle is | |
| 27 | +/// `Arc`-backed and the fetcher is behind `Arc<dyn RemoteFetcher>`. | |
| 28 | +#[derive(Clone)] | |
| 29 | +pub struct RemoteIdentityService { | |
| 30 | + db: DatabaseConnection, | |
| 31 | + fetcher: Arc<dyn RemoteFetcher>, | |
| 32 | + cache_ttl: Duration, | |
| 33 | +} | |
| 34 | + | |
| 35 | +impl RemoteIdentityService { | |
| 36 | + /// Build a new service with the given storage backend, fetcher, and | |
| 37 | + /// federation config. | |
| 38 | + pub fn new( | |
| 39 | + db: DatabaseConnection, | |
| 40 | + fetcher: Arc<dyn RemoteFetcher>, | |
| 41 | + config: &FediverseConfig, | |
| 42 | + ) -> Self { | |
| 43 | + Self { | |
| 44 | + db, | |
| 45 | + fetcher, | |
| 46 | + cache_ttl: config.fetch_actor_cache_ttl(), | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /// Return `true` when the cache row is older than the configured TTL | |
| 51 | + /// and should be refreshed. | |
| 52 | + pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool { | |
| 53 | + let elapsed = Utc::now().signed_duration_since(last_fetched_at); | |
| 54 | + match elapsed.to_std() { | |
| 55 | + Ok(d) => d >= self.cache_ttl, | |
| 56 | + // Negative durations: clock skew, treat as fresh. | |
| 57 | + Err(_) => false, | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Resolve a `@user@domain` handle. Returns the cached row if fresh; | |
| 62 | + /// otherwise calls the fetcher (WebFinger + actor JSON) and upserts. | |
| 63 | + pub async fn resolve_handle( | |
| 64 | + &self, | |
| 65 | + handle: &str, | |
| 66 | + ) -> Result<remote_identity::Model, FediverseError> { | |
| 67 | + let (username, domain) = parse_handle(handle)?; | |
| 68 | + | |
| 69 | + // Fast path: cache hit and fresh. | |
| 70 | + if let Some(cached) = self.find_by_handle(&username, &domain).await? { | |
| 71 | + if !self.is_stale(cached.last_fetched_at) { | |
| 72 | + return Ok(cached); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + // Slow path: fetch via WebFinger then by actor URL, then upsert. | |
| 77 | + let webfinger = self.fetcher.webfinger(handle).await?; | |
| 78 | + let actor_url = webfinger | |
| 79 | + .links | |
| 80 | + .iter() | |
| 81 | + .find(|l| { | |
| 82 | + l.rel == "self" | |
| 83 | + && l.media_type | |
| 84 | + .as_deref() | |
| 85 | + .is_some_and(|t| t.contains("activity+json") || t.contains("ld+json")) | |
| 86 | + }) | |
| 87 | + .and_then(|l| l.href.as_ref()) | |
| 88 | + .ok_or_else(|| { | |
| 89 | + FediverseError::InvalidResource(format!( | |
| 90 | + "WebFinger response for {handle} has no self/AP link" | |
| 91 | + )) | |
| 92 | + })?; | |
| 93 | + | |
| 94 | + let actor_url = url::Url::parse(actor_url).map_err(|e| { | |
| 95 | + FediverseError::InvalidResource(format!("invalid self link in WebFinger: {e}")) | |
| 96 | + })?; | |
| 97 | + let person = self.fetcher.fetch_actor(&actor_url).await?; | |
| 98 | + self.upsert_from_person(person, &domain).await | |
| 99 | + } | |
| 100 | + | |
| 101 | + /// Direct DB lookup by AP id. Returns `None` if no row matches. | |
| 102 | + pub async fn find_by_uri( | |
| 103 | + &self, | |
| 104 | + ap_id: &str, | |
| 105 | + ) -> Result<Option<remote_identity::Model>, AppError> { | |
| 106 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 107 | + Ok(remote_identity::Entity::find() | |
| 108 | + .filter(remote_identity::Column::ApId.eq(ap_id)) | |
| 109 | + .one(&self.db) | |
| 110 | + .await?) | |
| 111 | + } | |
| 112 | + | |
| 113 | + /// Direct DB lookup by handle. Returns `None` if no row matches. | |
| 114 | + pub async fn find_by_handle( | |
| 115 | + &self, | |
| 116 | + username: &str, | |
| 117 | + domain: &str, | |
| 118 | + ) -> Result<Option<remote_identity::Model>, AppError> { | |
| 119 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; | |
| 120 | + Ok(remote_identity::Entity::find() | |
| 121 | + .filter(remote_identity::Column::Username.eq(username)) | |
| 122 | + .filter(remote_identity::Column::Domain.eq(domain)) | |
| 123 | + .one(&self.db) | |
| 124 | + .await?) | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// Insert or update a remote identity from a freshly fetched PersonJson. | |
| 128 | + /// | |
| 129 | + /// `expected_domain` MUST match the host of `person.id`. Mismatches yield | |
| 130 | + /// [`FediverseError::DomainMismatch`] without touching the database, to | |
| 131 | + /// prevent trivial spoofing. | |
| 132 | + pub async fn upsert_from_person( | |
| 133 | + &self, | |
| 134 | + person: PersonJson, | |
| 135 | + expected_domain: &str, | |
| 136 | + ) -> Result<remote_identity::Model, FediverseError> { | |
| 137 | + use sea_orm::{ActiveModelTrait, Set}; | |
| 138 | + | |
| 139 | + let actor_host = person | |
| 140 | + .id | |
| 141 | + .host_str() | |
| 142 | + .ok_or_else(|| FediverseError::InvalidResource("actor id has no host".into()))?; | |
| 143 | + if !actor_host.eq_ignore_ascii_case(expected_domain) { | |
| 144 | + return Err(FediverseError::DomainMismatch); | |
| 145 | + } | |
| 146 | + | |
| 147 | + let raw_json = serde_json::to_value(&person).ok(); | |
| 148 | + let now = Utc::now(); | |
| 149 | + let public_key_pem = person | |
| 150 | + .public_key | |
| 151 | + .as_ref() | |
| 152 | + .map(|k| k.public_key_pem.clone()) | |
| 153 | + .unwrap_or_default(); | |
| 154 | + | |
| 155 | + if let Some(existing) = self | |
| 156 | + .find_by_uri(person.id.as_str()) | |
| 157 | + .await | |
| 158 | + .map_err(FediverseError::App)? | |
| 159 | + { | |
| 160 | + let mut active: remote_identity::ActiveModel = existing.into(); | |
| 161 | + active.username = Set(person.preferred_username.clone()); | |
| 162 | + active.domain = Set(actor_host.to_string()); | |
| 163 | + active.display_name = Set(person.name.clone()); | |
| 164 | + active.summary_html = Set(person.summary.clone()); | |
| 165 | + active.inbox_url = Set(person.inbox.to_string()); | |
| 166 | + active.shared_inbox_url = Set(person | |
| 167 | + .endpoints | |
| 168 | + .as_ref() | |
| 169 | + .map(|e| e.shared_inbox.to_string())); | |
| 170 | + active.public_key_pem = Set(public_key_pem); | |
| 171 | + active.actor_type = Set(person.kind.clone()); | |
| 172 | + active.raw_json = Set(raw_json); | |
| 173 | + active.last_fetched_at = Set(now); | |
| 174 | + active.updated_at = Set(now); | |
| 175 | + Ok(active.update(&self.db).await.map_err(AppError::from)?) | |
| 176 | + } else { | |
| 177 | + let active = remote_identity::ActiveModel { | |
| 178 | + id: Set(Uuid::new_v4()), | |
| 179 | + ap_id: Set(person.id.to_string()), | |
| 180 | + username: Set(person.preferred_username.clone()), | |
| 181 | + domain: Set(actor_host.to_string()), | |
| 182 | + display_name: Set(person.name.clone()), | |
| 183 | + summary_html: Set(person.summary.clone()), | |
| 184 | + inbox_url: Set(person.inbox.to_string()), | |
| 185 | + shared_inbox_url: Set(person | |
| 186 | + .endpoints | |
| 187 | + .as_ref() | |
| 188 | + .map(|e| e.shared_inbox.to_string())), | |
| 189 | + public_key_pem: Set(public_key_pem), | |
| 190 | + icon_url: Set(None), | |
| 191 | + actor_type: Set(person.kind.clone()), | |
| 192 | + raw_json: Set(raw_json), | |
| 193 | + last_fetched_at: Set(now), | |
| 194 | + disabled_at: Set(None), | |
| 195 | + created_at: Set(now), | |
| 196 | + updated_at: Set(now), | |
| 197 | + }; | |
| 198 | + Ok(active.insert(&self.db).await.map_err(AppError::from)?) | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + /// Mark an identity as disabled (used by `Delete{Actor}` in RFC 0010). | |
| 203 | + pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> { | |
| 204 | + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::Expr}; | |
| 205 | + | |
| 206 | + let now = Utc::now(); | |
| 207 | + remote_identity::Entity::update_many() | |
| 208 | + .col_expr( | |
| 209 | + remote_identity::Column::DisabledAt, | |
| 210 | + Expr::value(now), | |
| 211 | + ) | |
| 212 | + .col_expr( | |
| 213 | + remote_identity::Column::UpdatedAt, | |
| 214 | + Expr::value(now), | |
| 215 | + ) | |
| 216 | + .filter(remote_identity::Column::Id.eq(id)) | |
| 217 | + .exec(&self.db) | |
| 218 | + .await?; | |
| 219 | + // Touch unused-import lint | |
| 220 | + let _ = Set::<Option<DateTime<Utc>>>; | |
| 221 | + Ok(()) | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +/// Parse a `@user@domain` handle into `(username, domain)`. | |
| 226 | +/// | |
| 227 | +/// Accepts both `@user@domain` (Mastodon convention) and bare `user@domain`. | |
| 228 | +/// Returns [`FediverseError::InvalidResource`] if the format is malformed. | |
| 229 | +pub(crate) fn parse_handle(handle: &str) -> Result<(String, String), FediverseError> { | |
| 230 | + let trimmed = handle.trim(); | |
| 231 | + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed); | |
| 232 | + let (user, domain) = stripped.split_once('@').ok_or_else(|| { | |
| 233 | + FediverseError::InvalidResource(format!("missing @ separator in handle {handle}")) | |
| 234 | + })?; | |
| 235 | + if user.is_empty() || domain.is_empty() { | |
| 236 | + return Err(FediverseError::InvalidResource(format!( | |
| 237 | + "empty user or domain in handle {handle}" | |
| 238 | + ))); | |
| 239 | + } | |
| 240 | + Ok((user.to_string(), domain.to_string())) | |
| 241 | +} | |
| 242 | + | |
| 243 | +#[cfg(test)] | |
| 244 | +mod tests { | |
| 245 | + use super::*; | |
| 246 | + use crate::fetcher::UnconfiguredFetcher; | |
| 247 | + use chrono::Duration as ChronoDuration; | |
| 248 | + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult}; | |
| 249 | + | |
| 250 | + fn config_for_tests() -> FediverseConfig { | |
| 251 | + FediverseConfig::for_tests("example.org") | |
| 252 | + } | |
| 253 | + | |
| 254 | + fn make_service(db: DatabaseConnection) -> RemoteIdentityService { | |
| 255 | + RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()) | |
| 256 | + } | |
| 257 | + | |
| 258 | + fn sample_remote(domain: &str, username: &str) -> remote_identity::Model { | |
| 259 | + let now = Utc::now(); | |
| 260 | + let id = Uuid::new_v4(); | |
| 261 | + remote_identity::Model { | |
| 262 | + id, | |
| 263 | + ap_id: format!("https://{domain}/users/{username}"), | |
| 264 | + username: username.to_string(), | |
| 265 | + domain: domain.to_string(), | |
| 266 | + display_name: Some(username.to_string()), | |
| 267 | + summary_html: None, | |
| 268 | + inbox_url: format!("https://{domain}/users/{username}/inbox"), | |
| 269 | + shared_inbox_url: Some(format!("https://{domain}/inbox")), | |
| 270 | + public_key_pem: "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----" | |
| 271 | + .to_string(), | |
| 272 | + icon_url: None, | |
| 273 | + actor_type: "Person".to_string(), | |
| 274 | + raw_json: None, | |
| 275 | + last_fetched_at: now, | |
| 276 | + disabled_at: None, | |
| 277 | + created_at: now, | |
| 278 | + updated_at: now, | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 282 | + // ─── Test 1 — is_stale fresh ───────────────────────────────────── | |
| 283 | + | |
| 284 | + #[test] | |
| 285 | + fn is_stale_returns_false_within_ttl() { | |
| 286 | + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 287 | + let svc = make_service(db); | |
| 288 | + let recent = Utc::now() - ChronoDuration::seconds(60); | |
| 289 | + assert!(!svc.is_stale(recent)); | |
| 290 | + } | |
| 291 | + | |
| 292 | + // ─── Test 2 — is_stale stale ───────────────────────────────────── | |
| 293 | + | |
| 294 | + #[test] | |
| 295 | + fn is_stale_returns_true_past_ttl() { | |
| 296 | + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 297 | + let svc = make_service(db); | |
| 298 | + // Default TTL is 86 400 s. Use 2 days to be safely past. | |
| 299 | + let old = Utc::now() - ChronoDuration::days(2); | |
| 300 | + assert!(svc.is_stale(old)); | |
| 301 | + } | |
| 302 | + | |
| 303 | + // ─── Test 3 — parse_handle ─────────────────────────────────────── | |
| 304 | + | |
| 305 | + #[test] | |
| 306 | + fn parse_handle_accepts_mastodon_form() { | |
| 307 | + let (user, domain) = parse_handle("@alice@example.org").expect("valid"); | |
| 308 | + assert_eq!(user, "alice"); | |
| 309 | + assert_eq!(domain, "example.org"); | |
| 310 | + } | |
| 311 | + | |
| 312 | + #[test] | |
| 313 | + fn parse_handle_accepts_bare_form() { | |
| 314 | + let (user, domain) = parse_handle("alice@example.org").expect("valid"); | |
| 315 | + assert_eq!(user, "alice"); | |
| 316 | + assert_eq!(domain, "example.org"); | |
| 317 | + } | |
| 318 | + | |
| 319 | + #[test] | |
| 320 | + fn parse_handle_rejects_missing_at() { | |
| 321 | + let err = parse_handle("alice").expect_err("must fail"); | |
| 322 | + assert!(matches!(err, FediverseError::InvalidResource(_))); | |
| 323 | + } | |
| 324 | + | |
| 325 | + // ─── Test 4 — find_by_uri none ─────────────────────────────────── | |
| 326 | + | |
| 327 | + #[tokio::test] | |
| 328 | + async fn find_by_uri_returns_none_when_absent() { | |
| 329 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 330 | + .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 331 | + .into_connection(); | |
| 332 | + let svc = make_service(db); | |
| 333 | + let result = svc.find_by_uri("https://other.tld/users/ghost").await.unwrap(); | |
| 334 | + assert!(result.is_none()); | |
| 335 | + } | |
| 336 | + | |
| 337 | + // ─── Test 5 — find_by_uri some ─────────────────────────────────── | |
| 338 | + | |
| 339 | + #[tokio::test] | |
| 340 | + async fn find_by_uri_returns_record_when_present() { | |
| 341 | + let row = sample_remote("other.tld", "alice"); | |
| 342 | + let expected_id = row.id; | |
| 343 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 344 | + .append_query_results([vec![row]]) | |
| 345 | + .into_connection(); | |
| 346 | + let svc = make_service(db); | |
| 347 | + let result = svc | |
| 348 | + .find_by_uri("https://other.tld/users/alice") | |
| 349 | + .await | |
| 350 | + .unwrap() | |
| 351 | + .expect("must be present"); | |
| 352 | + assert_eq!(result.id, expected_id); | |
| 353 | + } | |
| 354 | + | |
| 355 | + // ─── Test 6 — find_by_handle none ──────────────────────────────── | |
| 356 | + | |
| 357 | + #[tokio::test] | |
| 358 | + async fn find_by_handle_returns_none_when_absent() { | |
| 359 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 360 | + .append_query_results([Vec::<remote_identity::Model>::new()]) | |
| 361 | + .into_connection(); | |
| 362 | + let svc = make_service(db); | |
| 363 | + let result = svc.find_by_handle("ghost", "other.tld").await.unwrap(); | |
| 364 | + assert!(result.is_none()); | |
| 365 | + } | |
| 366 | + | |
| 367 | + // ─── Test 7 — upsert rejects domain mismatch ───────────────────── | |
| 368 | + | |
| 369 | + #[tokio::test] | |
| 370 | + async fn upsert_rejects_domain_mismatch() { | |
| 371 | + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 372 | + let svc = make_service(db); | |
| 373 | + | |
| 374 | + let person = PersonJson { | |
| 375 | + context: crate::types::actor_context(), | |
| 376 | + kind: "Person".into(), | |
| 377 | + id: url::Url::parse("https://attacker.tld/users/alice").unwrap(), | |
| 378 | + preferred_username: "alice".into(), | |
| 379 | + name: None, | |
| 380 | + summary: None, | |
| 381 | + url: None, | |
| 382 | + inbox: url::Url::parse("https://attacker.tld/users/alice/inbox").unwrap(), | |
| 383 | + outbox: url::Url::parse("https://attacker.tld/users/alice/outbox").unwrap(), | |
| 384 | + followers: url::Url::parse("https://attacker.tld/users/alice/followers").unwrap(), | |
| 385 | + following: url::Url::parse("https://attacker.tld/users/alice/following").unwrap(), | |
| 386 | + endpoints: None, | |
| 387 | + public_key: None, | |
| 388 | + manually_approves_followers: false, | |
| 389 | + discoverable: true, | |
| 390 | + }; | |
| 391 | + | |
| 392 | + let err = svc | |
| 393 | + .upsert_from_person(person, "expected.tld") | |
| 394 | + .await | |
| 395 | + .expect_err("must fail"); | |
| 396 | + assert!( | |
| 397 | + matches!(err, FediverseError::DomainMismatch), | |
| 398 | + "got {err:?}" | |
| 399 | + ); | |
| 400 | + } | |
| 401 | + | |
| 402 | + // ─── Test 8 — mark_disabled ────────────────────────────────────── | |
| 403 | + | |
| 404 | + #[tokio::test] | |
| 405 | + async fn mark_disabled_sets_disabled_at() { | |
| 406 | + let id = Uuid::new_v4(); | |
| 407 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 408 | + .append_exec_results([MockExecResult { | |
| 409 | + last_insert_id: 0, | |
| 410 | + rows_affected: 1, | |
| 411 | + }]) | |
| 412 | + .into_connection(); | |
| 413 | + let svc = make_service(db); | |
| 414 | + svc.mark_disabled(id).await.unwrap(); | |
| 415 | + } | |
| 416 | + | |
| 417 | + // ─── Test 9 — resolve_handle returns cached when fresh ─────────── | |
| 418 | + | |
| 419 | + #[tokio::test] | |
| 420 | + async fn resolve_handle_returns_cached_when_fresh() { | |
| 421 | + let row = sample_remote("other.tld", "alice"); | |
| 422 | + let expected_id = row.id; | |
| 423 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 424 | + .append_query_results([vec![row]]) | |
| 425 | + .into_connection(); | |
| 426 | + // UnconfiguredFetcher would return Err if invoked — fresh cache must | |
| 427 | + // not invoke it. | |
| 428 | + let svc = RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests()); | |
| 429 | + let resolved = svc | |
| 430 | + .resolve_handle("@alice@other.tld") | |
| 431 | + .await | |
| 432 | + .expect("cached row"); | |
| 433 | + assert_eq!(resolved.id, expected_id); | |
| 434 | + } | |
| 435 | +} |
A
crates/rustwarden-fediverse/src/services/revocation.rs
+55
-0
@@ -0,0 +1,55 @@
| 1 | +//! SeaORM-backed implementation of | |
| 2 | +//! [`RemoteIdentityRevocationChecker`](rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker). | |
| 3 | +//! | |
| 4 | +//! Queries `remote_identities.disabled_at` for the given identifier and | |
| 5 | +//! returns `true` when the row is disabled. Database errors and missing | |
| 6 | +//! rows are treated as "not revoked" so a transient outage does not | |
| 7 | +//! lock every federated user out of the application. | |
| 8 | + | |
| 9 | +use async_trait::async_trait; | |
| 10 | +use sea_orm::{DatabaseConnection, EntityTrait}; | |
| 11 | +use uuid::Uuid; | |
| 12 | + | |
| 13 | +use rustwarden_core::middleware::revocation::RemoteIdentityRevocationChecker; | |
| 14 | + | |
| 15 | +use crate::models::remote_identity; | |
| 16 | + | |
| 17 | +/// Default revocation checker backed by the `remote_identities` table. | |
| 18 | +/// | |
| 19 | +/// Injected as `Arc<dyn RemoteIdentityRevocationChecker>` Tower | |
| 20 | +/// `Extension` from | |
| 21 | +/// [`crate::routes::router`](crate::routes::router) when the federation | |
| 22 | +/// stack is mounted on a Rustwarden builder. | |
| 23 | +#[derive(Clone)] | |
| 24 | +pub struct SeaOrmRemoteIdentityRevocationChecker { | |
| 25 | + /// Database handle used to look up the `remote_identities` row. | |
| 26 | + pub db: DatabaseConnection, | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl SeaOrmRemoteIdentityRevocationChecker { | |
| 30 | + /// Wrap a `DatabaseConnection` into a revocation checker. | |
| 31 | + pub fn new(db: DatabaseConnection) -> Self { | |
| 32 | + Self { db } | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +#[async_trait] | |
| 37 | +impl RemoteIdentityRevocationChecker for SeaOrmRemoteIdentityRevocationChecker { | |
| 38 | + async fn is_revoked(&self, remote_identity_id: Uuid) -> bool { | |
| 39 | + match remote_identity::Entity::find_by_id(remote_identity_id) | |
| 40 | + .one(&self.db) | |
| 41 | + .await | |
| 42 | + { | |
| 43 | + Ok(Some(row)) => row.disabled_at.is_some(), | |
| 44 | + Ok(None) => false, | |
| 45 | + Err(err) => { | |
| 46 | + tracing::warn!( | |
| 47 | + "SeaOrmRemoteIdentityRevocationChecker: lookup failed for {}: {}", | |
| 48 | + remote_identity_id, | |
| 49 | + err | |
| 50 | + ); | |
| 51 | + false | |
| 52 | + } | |
| 53 | + } | |
| 54 | + } | |
| 55 | +} |
A
crates/rustwarden-fediverse/src/state.rs
+28
-0
@@ -0,0 +1,28 @@
| 1 | +//! Shared state injected into federation handlers via Axum extensions. | |
| 2 | + | |
| 3 | +use std::sync::Arc; | |
| 4 | + | |
| 5 | +use crate::config::FediverseConfig; | |
| 6 | + | |
| 7 | +use super::resolver::LocalUserResolver; | |
| 8 | + | |
| 9 | +/// Injected into every federation handler via `Extension<FediverseState>`. | |
| 10 | +/// Carries the runtime configuration and a handle to the user resolver. | |
| 11 | +/// | |
| 12 | +/// Cheap to clone — the resolver is behind `Arc`, the config is `Clone`. | |
| 13 | +#[derive(Clone)] | |
| 14 | +pub struct FediverseState { | |
| 15 | + /// Runtime configuration loaded by [`crate::app::RustwardenBuilder::with_fediverse`]. | |
| 16 | + pub config: FediverseConfig, | |
| 17 | + /// User store projection used by handlers. | |
| 18 | + pub resolver: Arc<dyn LocalUserResolver>, | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl std::fmt::Debug for FediverseState { | |
| 22 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 23 | + f.debug_struct("FediverseState") | |
| 24 | + .field("config", &self.config) | |
| 25 | + .field("resolver", &"<dyn LocalUserResolver>") | |
| 26 | + .finish() | |
| 27 | + } | |
| 28 | +} |
A
crates/rustwarden-fediverse/src/types.rs
+219
-0
@@ -0,0 +1,219 @@
| 1 | +//! JSON types serialised on federation endpoints (Person, WebFinger JRD, | |
| 2 | +//! NodeInfo). All field names are stabilised to match the de facto Mastodon | |
| 3 | +//! / W3C ActivityStreams contracts. | |
| 4 | + | |
| 5 | +use serde::{Deserialize, Serialize}; | |
| 6 | +use url::Url; | |
| 7 | + | |
| 8 | +/// JSON-LD `@context` shared by every actor we publish. | |
| 9 | +/// | |
| 10 | +/// Includes: ActivityStreams 2.0 core vocabulary, W3C security vocabulary | |
| 11 | +/// (`publicKey` namespace), and Mastodon's `toot:` extension namespace | |
| 12 | +/// (covers `discoverable`, `indexable`, `Emoji`). | |
| 13 | +pub fn actor_context() -> serde_json::Value { | |
| 14 | + serde_json::json!([ | |
| 15 | + "https://www.w3.org/ns/activitystreams", | |
| 16 | + "https://w3id.org/security/v1", | |
| 17 | + { | |
| 18 | + "toot": "http://joinmastodon.org/ns#", | |
| 19 | + "discoverable": "toot:discoverable", | |
| 20 | + "indexable": "toot:indexable", | |
| 21 | + "Emoji": "toot:Emoji" | |
| 22 | + } | |
| 23 | + ]) | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// ActivityPub Person actor (W3C ActivityStreams 2.0 + Security V1 + | |
| 27 | +/// Mastodon extensions). | |
| 28 | +/// | |
| 29 | +/// Field `public_key` is `None` until [RFC 0005](../../rfcs/0005-actor-key-storage.md) | |
| 30 | +/// (`actor-key-storage`) lands. Until then, the actor is **discoverable** but | |
| 31 | +/// not **federable** — Mastodon will refuse to deliver activities to an actor | |
| 32 | +/// without a published `publicKey`. | |
| 33 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 34 | +#[serde(rename_all = "camelCase")] | |
| 35 | +pub struct PersonJson { | |
| 36 | + /// JSON-LD context, see [`actor_context`]. | |
| 37 | + #[serde(rename = "@context")] | |
| 38 | + pub context: serde_json::Value, | |
| 39 | + /// Always the literal string `"Person"`. | |
| 40 | + #[serde(rename = "type")] | |
| 41 | + pub kind: String, | |
| 42 | + /// Canonical actor URL, e.g. `https://example.org/users/alice`. | |
| 43 | + pub id: Url, | |
| 44 | + /// Local username (the `alice` part of `@alice@example.org`). | |
| 45 | + pub preferred_username: String, | |
| 46 | + /// Optional display name shown in clients. | |
| 47 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 48 | + pub name: Option<String>, | |
| 49 | + /// HTML bio (sanitised before insertion into DB; rendered as-is here). | |
| 50 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 51 | + pub summary: Option<String>, | |
| 52 | + /// Profile page URL (HTML). | |
| 53 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 54 | + pub url: Option<Url>, | |
| 55 | + /// Inbox endpoint where remote servers POST activities for this actor. | |
| 56 | + pub inbox: Url, | |
| 57 | + /// Outbox endpoint listing this actor's published activities. | |
| 58 | + pub outbox: Url, | |
| 59 | + /// Followers collection URL. | |
| 60 | + pub followers: Url, | |
| 61 | + /// Following collection URL. | |
| 62 | + pub following: Url, | |
| 63 | + /// Mastodon-compatible shared inbox endpoint (instance-wide). | |
| 64 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 65 | + pub endpoints: Option<EndpointsJson>, | |
| 66 | + /// Public RSA key used to verify HTTP signatures. `None` until RFC 0005. | |
| 67 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 68 | + pub public_key: Option<PublicKeyJson>, | |
| 69 | + /// When `true`, follow requests await manual approval. | |
| 70 | + #[serde(default)] | |
| 71 | + pub manually_approves_followers: bool, | |
| 72 | + /// Mastodon `toot:discoverable` flag. | |
| 73 | + #[serde(default = "default_true")] | |
| 74 | + pub discoverable: bool, | |
| 75 | +} | |
| 76 | + | |
| 77 | +fn default_true() -> bool { | |
| 78 | + true | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// Public RSA key embedded in a [`PersonJson`]. | |
| 82 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 83 | +#[serde(rename_all = "camelCase")] | |
| 84 | +pub struct PublicKeyJson { | |
| 85 | + /// Key identifier, conventionally `{actor_id}#main-key`. | |
| 86 | + pub id: Url, | |
| 87 | + /// URL of the actor that owns this key. | |
| 88 | + pub owner: Url, | |
| 89 | + /// PEM-encoded SubjectPublicKeyInfo (RSA-2048+). | |
| 90 | + pub public_key_pem: String, | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Mastodon-compatible `endpoints` object on a Person actor. | |
| 94 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 95 | +#[serde(rename_all = "camelCase")] | |
| 96 | +pub struct EndpointsJson { | |
| 97 | + /// Shared inbox URL (one per instance, used for fan-out optimisation). | |
| 98 | + pub shared_inbox: Url, | |
| 99 | +} | |
| 100 | + | |
| 101 | +// ───────────────────────── WebFinger (RFC 7033) ───────────────────────── | |
| 102 | + | |
| 103 | +/// WebFinger JRD response document. | |
| 104 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 105 | +pub struct WebFingerResponse { | |
| 106 | + /// Subject URI, must echo the `resource` query parameter. | |
| 107 | + pub subject: String, | |
| 108 | + /// Optional alternative URIs for the same subject. | |
| 109 | + #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 110 | + pub aliases: Vec<String>, | |
| 111 | + /// Typed links pointing at the various representations of the subject. | |
| 112 | + pub links: Vec<WebFingerLink>, | |
| 113 | +} | |
| 114 | + | |
| 115 | +/// Single typed link inside a [`WebFingerResponse`]. | |
| 116 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 117 | +pub struct WebFingerLink { | |
| 118 | + /// Link relation, e.g. `"self"` or | |
| 119 | + /// `"http://webfinger.net/rel/profile-page"`. | |
| 120 | + pub rel: String, | |
| 121 | + /// MIME type of the resource. | |
| 122 | + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] | |
| 123 | + pub media_type: Option<String>, | |
| 124 | + /// Concrete URL of the linked resource (mutually exclusive with | |
| 125 | + /// `template`). | |
| 126 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 127 | + pub href: Option<String>, | |
| 128 | + /// URI template (RFC 6570) when the relation is interaction-driven. | |
| 129 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 130 | + pub template: Option<String>, | |
| 131 | +} | |
| 132 | + | |
| 133 | +// ───────────────────────── NodeInfo (Diaspora) ────────────────────────── | |
| 134 | + | |
| 135 | +/// NodeInfo discovery document (`/.well-known/nodeinfo`). | |
| 136 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 137 | +pub struct NodeInfoDiscovery { | |
| 138 | + /// Links pointing at concrete NodeInfo schema versions. | |
| 139 | + pub links: Vec<NodeInfoLink>, | |
| 140 | +} | |
| 141 | + | |
| 142 | +/// Single link in a [`NodeInfoDiscovery`] document. | |
| 143 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 144 | +pub struct NodeInfoLink { | |
| 145 | + /// Schema relation, e.g. | |
| 146 | + /// `http://nodeinfo.diaspora.software/ns/schema/2.1`. | |
| 147 | + pub rel: String, | |
| 148 | + /// URL of the concrete NodeInfo resource. | |
| 149 | + pub href: String, | |
| 150 | +} | |
| 151 | + | |
| 152 | +/// NodeInfo 2.1 detail document (`/nodeinfo/2.1`). | |
| 153 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 154 | +#[serde(rename_all = "camelCase")] | |
| 155 | +pub struct NodeInfo { | |
| 156 | + /// Always `"2.1"`. | |
| 157 | + pub version: String, | |
| 158 | + /// Software name + version metadata. | |
| 159 | + pub software: NodeInfoSoftware, | |
| 160 | + /// Federation protocols supported, always at least `["activitypub"]`. | |
| 161 | + pub protocols: Vec<String>, | |
| 162 | + /// Inbound/outbound services bridged by this instance (empty for now). | |
| 163 | + pub services: NodeInfoServices, | |
| 164 | + /// Aggregated usage counters. | |
| 165 | + pub usage: NodeInfoUsage, | |
| 166 | + /// Whether new account registration is open to the public. | |
| 167 | + pub open_registrations: bool, | |
| 168 | + /// Free-form metadata (instance description, contact, …). | |
| 169 | + #[serde(default)] | |
| 170 | + pub metadata: serde_json::Value, | |
| 171 | +} | |
| 172 | + | |
| 173 | +/// Software identification block of a [`NodeInfo`]. | |
| 174 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 175 | +pub struct NodeInfoSoftware { | |
| 176 | + /// Lowercase software name, e.g. `"rustwarden"`. | |
| 177 | + pub name: String, | |
| 178 | + /// SemVer software version. | |
| 179 | + pub version: String, | |
| 180 | + /// Optional VCS repository URL. | |
| 181 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 182 | + pub repository: Option<String>, | |
| 183 | + /// Optional homepage URL. | |
| 184 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 185 | + pub homepage: Option<String>, | |
| 186 | +} | |
| 187 | + | |
| 188 | +/// Services bridge advertised by NodeInfo. | |
| 189 | +#[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 190 | +pub struct NodeInfoServices { | |
| 191 | + /// Inbound bridges (empty for Rustwarden). | |
| 192 | + #[serde(default)] | |
| 193 | + pub inbound: Vec<String>, | |
| 194 | + /// Outbound bridges (empty for Rustwarden). | |
| 195 | + #[serde(default)] | |
| 196 | + pub outbound: Vec<String>, | |
| 197 | +} | |
| 198 | + | |
| 199 | +/// Aggregated usage counters in [`NodeInfo`]. | |
| 200 | +#[derive(Debug, Clone, Serialize, Deserialize)] | |
| 201 | +#[serde(rename_all = "camelCase")] | |
| 202 | +pub struct NodeInfoUsage { | |
| 203 | + /// User counters split by activity window. | |
| 204 | + pub users: NodeInfoUsageUsers, | |
| 205 | + /// Total number of locally-authored posts. | |
| 206 | + pub local_posts: u64, | |
| 207 | +} | |
| 208 | + | |
| 209 | +/// User-count split returned in [`NodeInfoUsage`]. | |
| 210 | +#[derive(Debug, Clone, Default, Serialize, Deserialize)] | |
| 211 | +#[serde(rename_all = "camelCase")] | |
| 212 | +pub struct NodeInfoUsageUsers { | |
| 213 | + /// Total registered users. | |
| 214 | + pub total: u64, | |
| 215 | + /// Users active in the last 30 days. | |
| 216 | + pub active_month: u64, | |
| 217 | + /// Users active in the last 180 days. | |
| 218 | + pub active_halfyear: u64, | |
| 219 | +} |
A
crates/rustwarden-fediverse/src/webfinger.rs
+280
-0
@@ -0,0 +1,280 @@
| 1 | +//! WebFinger handler (RFC 7033). | |
| 2 | +//! | |
| 3 | +//! Resolves `@user@domain` handles to ActivityPub actor URLs. Mounted at | |
| 4 | +//! `GET /.well-known/webfinger`. | |
| 5 | + | |
| 6 | +use axum::{ | |
| 7 | + Extension, Json, | |
| 8 | + extract::Query, | |
| 9 | + http::{HeaderValue, StatusCode, header}, | |
| 10 | + response::{IntoResponse, Response}, | |
| 11 | +}; | |
| 12 | +use serde::Deserialize; | |
| 13 | + | |
| 14 | +use super::error::FediverseError; | |
| 15 | +use super::state::FediverseState; | |
| 16 | +use super::types::{WebFingerLink, WebFingerResponse}; | |
| 17 | + | |
| 18 | +/// Query parameters for the WebFinger handler. | |
| 19 | +#[derive(Debug, Deserialize)] | |
| 20 | +pub struct WebFingerQuery { | |
| 21 | + /// Handle being queried, e.g. `acct:alice@example.org`. | |
| 22 | + pub resource: String, | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// `GET /.well-known/webfinger?resource=acct:user@domain`. | |
| 26 | +/// | |
| 27 | +/// Returns a JRD describing how to find the actor's ActivityPub | |
| 28 | +/// representation. Sets `Access-Control-Allow-Origin: *` (RFC 7033 §3.2). | |
| 29 | +pub async fn handle( | |
| 30 | + Query(query): Query<WebFingerQuery>, | |
| 31 | + Extension(state): Extension<FediverseState>, | |
| 32 | +) -> Result<Response, FediverseError> { | |
| 33 | + let (username, domain) = parse_acct_resource(&query.resource)?; | |
| 34 | + | |
| 35 | + if domain != state.config.domain() { | |
| 36 | + return Err(FediverseError::DomainMismatch); | |
| 37 | + } | |
| 38 | + | |
| 39 | + let user = state | |
| 40 | + .resolver | |
| 41 | + .find_local_user(&username) | |
| 42 | + .await? | |
| 43 | + .ok_or(FediverseError::UserNotFound)?; | |
| 44 | + | |
| 45 | + let actor_url = format!("{}users/{}", state.config.base_url(), user.username); | |
| 46 | + let profile_url = format!("{}@{}", state.config.base_url(), user.username); | |
| 47 | + | |
| 48 | + let body = WebFingerResponse { | |
| 49 | + subject: query.resource.clone(), | |
| 50 | + aliases: vec![actor_url.clone(), profile_url.clone()], | |
| 51 | + links: vec![ | |
| 52 | + WebFingerLink { | |
| 53 | + rel: "self".to_string(), | |
| 54 | + media_type: Some("application/activity+json".to_string()), | |
| 55 | + href: Some(actor_url), | |
| 56 | + template: None, | |
| 57 | + }, | |
| 58 | + WebFingerLink { | |
| 59 | + rel: "http://webfinger.net/rel/profile-page".to_string(), | |
| 60 | + media_type: Some("text/html".to_string()), | |
| 61 | + href: Some(profile_url), | |
| 62 | + template: None, | |
| 63 | + }, | |
| 64 | + ], | |
| 65 | + }; | |
| 66 | + | |
| 67 | + Ok(( | |
| 68 | + StatusCode::OK, | |
| 69 | + [ | |
| 70 | + ( | |
| 71 | + header::CONTENT_TYPE, | |
| 72 | + HeaderValue::from_static("application/jrd+json"), | |
| 73 | + ), | |
| 74 | + ( | |
| 75 | + header::ACCESS_CONTROL_ALLOW_ORIGIN, | |
| 76 | + HeaderValue::from_static("*"), | |
| 77 | + ), | |
| 78 | + ], | |
| 79 | + Json(body), | |
| 80 | + ) | |
| 81 | + .into_response()) | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// Parse a `resource=acct:user@domain` value into `(username, domain)`. | |
| 85 | +/// | |
| 86 | +/// Returns [`FediverseError::InvalidResource`] if the prefix is missing or | |
| 87 | +/// the `@` separator is absent or any side is empty. | |
| 88 | +pub(crate) fn parse_acct_resource(resource: &str) -> Result<(String, String), FediverseError> { | |
| 89 | + let acct = resource | |
| 90 | + .strip_prefix("acct:") | |
| 91 | + .ok_or_else(|| FediverseError::InvalidResource(format!("missing acct: prefix in {resource}")))?; | |
| 92 | + | |
| 93 | + let (user, domain) = acct.split_once('@').ok_or_else(|| { | |
| 94 | + FediverseError::InvalidResource(format!("missing @ separator in {resource}")) | |
| 95 | + })?; | |
| 96 | + | |
| 97 | + if user.is_empty() || domain.is_empty() { | |
| 98 | + return Err(FediverseError::InvalidResource(format!( | |
| 99 | + "empty user or domain in {resource}" | |
| 100 | + ))); | |
| 101 | + } | |
| 102 | + | |
| 103 | + Ok((user.to_string(), domain.to_string())) | |
| 104 | +} | |
| 105 | + | |
| 106 | +#[cfg(test)] | |
| 107 | +mod tests { | |
| 108 | + use super::*; | |
| 109 | + use crate::config::FediverseConfig; | |
| 110 | + use crate::resolver::{LocalUserResolver, UserSummary}; | |
| 111 | + use async_trait::async_trait; | |
| 112 | + use axum::Router; | |
| 113 | + use axum::body::Body; | |
| 114 | + use axum::http::Request; | |
| 115 | + use axum::routing::get; | |
| 116 | + use std::sync::Arc; | |
| 117 | + use tower::ServiceExt; | |
| 118 | + use uuid::Uuid; | |
| 119 | + | |
| 120 | + /// Stub resolver returning predefined users. | |
| 121 | + struct StubResolver { | |
| 122 | + user: Option<UserSummary>, | |
| 123 | + } | |
| 124 | + | |
| 125 | + #[async_trait] | |
| 126 | + impl LocalUserResolver for StubResolver { | |
| 127 | + async fn find_local_user( | |
| 128 | + &self, | |
| 129 | + _username: &str, | |
| 130 | + ) -> Result<Option<UserSummary>, rustwarden_core::error::AppError> { | |
| 131 | + Ok(self.user.clone()) | |
| 132 | + } | |
| 133 | + async fn count_users_total(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 134 | + Ok(0) | |
| 135 | + } | |
| 136 | + async fn count_users_active_since(&self, _days: u32) -> Result<u64, rustwarden_core::error::AppError> { | |
| 137 | + Ok(0) | |
| 138 | + } | |
| 139 | + async fn count_local_posts(&self) -> Result<u64, rustwarden_core::error::AppError> { | |
| 140 | + Ok(0) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router { | |
| 145 | + let state = FediverseState { | |
| 146 | + config: FediverseConfig::for_tests(domain), | |
| 147 | + resolver: Arc::new(StubResolver { user }), | |
| 148 | + }; | |
| 149 | + Router::new() | |
| 150 | + .route("/.well-known/webfinger", get(handle)) | |
| 151 | + .layer(Extension(state)) | |
| 152 | + } | |
| 153 | + | |
| 154 | + fn alice() -> UserSummary { | |
| 155 | + UserSummary { | |
| 156 | + id: Uuid::new_v4(), | |
| 157 | + username: "alice".into(), | |
| 158 | + display_name: Some("Alice".into()), | |
| 159 | + bio: None, | |
| 160 | + ap_public_key_pem: None, | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + async fn body_json(response: Response) -> serde_json::Value { | |
| 165 | + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 166 | + .await | |
| 167 | + .expect("body bytes"); | |
| 168 | + serde_json::from_slice(&bytes).expect("body is valid JSON") | |
| 169 | + } | |
| 170 | + | |
| 171 | + #[tokio::test] | |
| 172 | + async fn webfinger_returns_jrd_for_existing_local_user() { | |
| 173 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 174 | + | |
| 175 | + let response = app | |
| 176 | + .oneshot( | |
| 177 | + Request::builder() | |
| 178 | + .uri("/.well-known/webfinger?resource=acct:alice@example.org") | |
| 179 | + .body(Body::empty()) | |
| 180 | + .unwrap(), | |
| 181 | + ) | |
| 182 | + .await | |
| 183 | + .unwrap(); | |
| 184 | + | |
| 185 | + assert_eq!(response.status(), StatusCode::OK); | |
| 186 | + assert_eq!( | |
| 187 | + response | |
| 188 | + .headers() | |
| 189 | + .get(header::CONTENT_TYPE) | |
| 190 | + .map(HeaderValue::to_str) | |
| 191 | + .transpose() | |
| 192 | + .unwrap() | |
| 193 | + .unwrap_or_default(), | |
| 194 | + "application/jrd+json" | |
| 195 | + ); | |
| 196 | + assert_eq!( | |
| 197 | + response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN), | |
| 198 | + Some(&HeaderValue::from_static("*")) | |
| 199 | + ); | |
| 200 | + | |
| 201 | + let body = body_json(response).await; | |
| 202 | + assert_eq!(body["subject"], "acct:alice@example.org"); | |
| 203 | + let self_link = body["links"] | |
| 204 | + .as_array() | |
| 205 | + .expect("links array") | |
| 206 | + .iter() | |
| 207 | + .find(|l| l["rel"] == "self") | |
| 208 | + .expect("self link present"); | |
| 209 | + assert_eq!(self_link["type"], "application/activity+json"); | |
| 210 | + assert_eq!(self_link["href"], "https://example.org/users/alice"); | |
| 211 | + } | |
| 212 | + | |
| 213 | + #[tokio::test] | |
| 214 | + async fn webfinger_returns_404_for_unknown_user() { | |
| 215 | + let app = router_with_resolver("example.org", None); | |
| 216 | + | |
| 217 | + let response = app | |
| 218 | + .oneshot( | |
| 219 | + Request::builder() | |
| 220 | + .uri("/.well-known/webfinger?resource=acct:bob@example.org") | |
| 221 | + .body(Body::empty()) | |
| 222 | + .unwrap(), | |
| 223 | + ) | |
| 224 | + .await | |
| 225 | + .unwrap(); | |
| 226 | + | |
| 227 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 228 | + } | |
| 229 | + | |
| 230 | + #[tokio::test] | |
| 231 | + async fn webfinger_returns_404_when_domain_mismatches() { | |
| 232 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 233 | + | |
| 234 | + let response = app | |
| 235 | + .oneshot( | |
| 236 | + Request::builder() | |
| 237 | + .uri("/.well-known/webfinger?resource=acct:alice@other.tld") | |
| 238 | + .body(Body::empty()) | |
| 239 | + .unwrap(), | |
| 240 | + ) | |
| 241 | + .await | |
| 242 | + .unwrap(); | |
| 243 | + | |
| 244 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 245 | + } | |
| 246 | + | |
| 247 | + #[tokio::test] | |
| 248 | + async fn webfinger_returns_400_for_malformed_resource() { | |
| 249 | + let app = router_with_resolver("example.org", Some(alice())); | |
| 250 | + | |
| 251 | + for bad in [ | |
| 252 | + "/.well-known/webfinger?resource=alice@example.org", | |
| 253 | + "/.well-known/webfinger?resource=acct:noatdomain", | |
| 254 | + "/.well-known/webfinger?resource=acct:", | |
| 255 | + ] { | |
| 256 | + let response = app | |
| 257 | + .clone() | |
| 258 | + .oneshot(Request::builder().uri(bad).body(Body::empty()).unwrap()) | |
| 259 | + .await | |
| 260 | + .unwrap(); | |
| 261 | + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "bad uri={bad}"); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + #[test] | |
| 266 | + fn parse_acct_resource_accepts_valid_handle() { | |
| 267 | + let (user, domain) = parse_acct_resource("acct:alice@example.org").expect("valid"); | |
| 268 | + assert_eq!(user, "alice"); | |
| 269 | + assert_eq!(domain, "example.org"); | |
| 270 | + } | |
| 271 | + | |
| 272 | + #[test] | |
| 273 | + fn parse_acct_resource_rejects_missing_prefix() { | |
| 274 | + let err = parse_acct_resource("alice@example.org").expect_err("must fail"); | |
| 275 | + assert!( | |
| 276 | + matches!(err, FediverseError::InvalidResource(_)), | |
| 277 | + "got {err:?}" | |
| 278 | + ); | |
| 279 | + } | |
| 280 | +} |
A
crates/rustwarden-fediverse/tests/e2e_fediverse.rs
+1995
-0
@@ -0,0 +1,1995 @@
| 1 | +//! End-to-end smoke tests against the docker-compose fediverse testbed. | |
| 2 | +//! | |
| 3 | +//! Run with: | |
| 4 | +//! ```text | |
| 5 | +//! cd tests/fediverse-testbed && ./scripts/setup.sh | |
| 6 | +//! cargo test -p rustwarden-core --test e2e_fediverse \ | |
| 7 | +//! --features experimental-fediverse | |
| 8 | +//! ``` | |
| 9 | +//! | |
| 10 | +//! Tests skip gracefully (without failing) if the testbed is not running, so | |
| 11 | +//! they can stay in the default test suite without breaking developers who | |
| 12 | +//! haven't booted the testbed. | |
| 13 | + | |
| 14 | + | |
| 15 | +use base64::Engine; | |
| 16 | +use reqwest::{Client, StatusCode}; | |
| 17 | +use rustwarden_fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 18 | +use rustwarden_core::services::crypto_service::CryptoService; | |
| 19 | +use serde_json::{json, Value}; | |
| 20 | +use std::process::Command; | |
| 21 | +use uuid::Uuid; | |
| 22 | + | |
| 23 | +const ALPHA: &str = "http://localhost:3001"; | |
| 24 | +const BETA: &str = "http://localhost:3002"; | |
| 25 | +const GAMMA: &str = "http://localhost:3003"; | |
| 26 | + | |
| 27 | +const INSTANCES: &[(&str, &str)] = &[ | |
| 28 | + (ALPHA, "alpha.local"), | |
| 29 | + (BETA, "beta.local"), | |
| 30 | + (GAMMA, "gamma.local"), | |
| 31 | +]; | |
| 32 | + | |
| 33 | +/// Probe `/health` on the three instances. Returns a configured client if | |
| 34 | +/// every probe succeeds, `None` otherwise (printing a hint for the developer). | |
| 35 | +async fn testbed_or_skip() -> Option<Client> { | |
| 36 | + let client = Client::builder() | |
| 37 | + .timeout(std::time::Duration::from_secs(5)) | |
| 38 | + .build() | |
| 39 | + .expect("reqwest client builder must not fail"); | |
| 40 | + | |
| 41 | + for (base, _) in INSTANCES { | |
| 42 | + match client.get(format!("{base}/health")).send().await { | |
| 43 | + Ok(r) if r.status() == StatusCode::OK => continue, | |
| 44 | + other => { | |
| 45 | + eprintln!( | |
| 46 | + "[e2e_fediverse] testbed unreachable at {base}: {other:?}\n\ | |
| 47 | + [e2e_fediverse] start with: tests/fediverse-testbed/scripts/setup.sh\n\ | |
| 48 | + [e2e_fediverse] skipping E2E test." | |
| 49 | + ); | |
| 50 | + return None; | |
| 51 | + } | |
| 52 | + } | |
| 53 | + } | |
| 54 | + Some(client) | |
| 55 | +} | |
| 56 | + | |
| 57 | +#[tokio::test] | |
| 58 | +async fn webfinger_resolves_local_user_on_each_instance() { | |
| 59 | + let Some(client) = testbed_or_skip().await else { | |
| 60 | + return; | |
| 61 | + }; | |
| 62 | + for (base, domain) in INSTANCES { | |
| 63 | + let response = client | |
| 64 | + .get(format!("{base}/.well-known/webfinger")) | |
| 65 | + .query(&[("resource", format!("acct:joanie@{domain}"))]) | |
| 66 | + .send() | |
| 67 | + .await | |
| 68 | + .expect("request must succeed"); | |
| 69 | + assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 70 | + let jrd: Value = response.json().await.expect("body must be JSON"); | |
| 71 | + assert_eq!(jrd["subject"], format!("acct:joanie@{domain}")); | |
| 72 | + let links = jrd["links"].as_array().expect("links must be array"); | |
| 73 | + let has_self_ap = links.iter().any(|l| { | |
| 74 | + l["rel"] == "self" && l["type"] == "application/activity+json" | |
| 75 | + }); | |
| 76 | + assert!(has_self_ap, "JRD on {base} must expose a self link with AP type"); | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +#[tokio::test] | |
| 81 | +async fn webfinger_returns_404_for_foreign_domain() { | |
| 82 | + let Some(client) = testbed_or_skip().await else { | |
| 83 | + return; | |
| 84 | + }; | |
| 85 | + // Alpha must not vouch for an account on beta's domain. | |
| 86 | + let response = client | |
| 87 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 88 | + .query(&[("resource", "acct:joanie@beta.local")]) | |
| 89 | + .send() | |
| 90 | + .await | |
| 91 | + .expect("request must succeed"); | |
| 92 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 93 | +} | |
| 94 | + | |
| 95 | +#[tokio::test] | |
| 96 | +async fn webfinger_returns_4xx_for_malformed_resource() { | |
| 97 | + let Some(client) = testbed_or_skip().await else { | |
| 98 | + return; | |
| 99 | + }; | |
| 100 | + let response = client | |
| 101 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 102 | + .query(&[("resource", "not-an-acct-uri")]) | |
| 103 | + .send() | |
| 104 | + .await | |
| 105 | + .expect("request must succeed"); | |
| 106 | + assert!( | |
| 107 | + response.status().is_client_error(), | |
| 108 | + "expected 4xx for malformed resource, got {}", | |
| 109 | + response.status() | |
| 110 | + ); | |
| 111 | +} | |
| 112 | + | |
| 113 | +#[tokio::test] | |
| 114 | +async fn actor_fetch_returns_valid_person_jsonld() { | |
| 115 | + let Some(client) = testbed_or_skip().await else { | |
| 116 | + return; | |
| 117 | + }; | |
| 118 | + for (base, domain) in INSTANCES { | |
| 119 | + let response = client | |
| 120 | + .get(format!("{base}/users/joanie")) | |
| 121 | + .header("Accept", "application/activity+json") | |
| 122 | + .send() | |
| 123 | + .await | |
| 124 | + .expect("request must succeed"); | |
| 125 | + assert_eq!(response.status(), StatusCode::OK, "{base}"); | |
| 126 | + let actor: Value = response.json().await.expect("body must be JSON"); | |
| 127 | + assert_eq!(actor["type"], "Person"); | |
| 128 | + assert_eq!(actor["preferredUsername"], "joanie"); | |
| 129 | + assert_eq!(actor["id"], format!("http://{domain}/users/joanie")); | |
| 130 | + assert_eq!( | |
| 131 | + actor["inbox"], | |
| 132 | + format!("http://{domain}/users/joanie/inbox") | |
| 133 | + ); | |
| 134 | + let key = &actor["publicKey"]; | |
| 135 | + assert!( | |
| 136 | + key["publicKeyPem"] | |
| 137 | + .as_str() | |
| 138 | + .unwrap_or_default() | |
| 139 | + .contains("BEGIN PUBLIC KEY"), | |
| 140 | + "actor on {base} must expose a PEM publicKey" | |
| 141 | + ); | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +#[tokio::test] | |
| 146 | +async fn nodeinfo_discovery_returns_links() { | |
| 147 | + let Some(client) = testbed_or_skip().await else { | |
| 148 | + return; | |
| 149 | + }; | |
| 150 | + let response = client | |
| 151 | + .get(format!("{ALPHA}/.well-known/nodeinfo")) | |
| 152 | + .send() | |
| 153 | + .await | |
| 154 | + .expect("request must succeed"); | |
| 155 | + assert_eq!(response.status(), StatusCode::OK); | |
| 156 | + let body: Value = response.json().await.expect("body must be JSON"); | |
| 157 | + let links = body["links"].as_array().expect("links must be array"); | |
| 158 | + assert!( | |
| 159 | + links.iter().any(|l| l["rel"] | |
| 160 | + .as_str() | |
| 161 | + .unwrap_or_default() | |
| 162 | + .contains("nodeinfo")), | |
| 163 | + "nodeinfo discovery must expose at least one nodeinfo link" | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +#[tokio::test] | |
| 168 | +async fn auth_inbox_rejects_unsigned_post() { | |
| 169 | + let Some(client) = testbed_or_skip().await else { | |
| 170 | + return; | |
| 171 | + }; | |
| 172 | + // No Signature header, no Date, no Digest — must be refused. | |
| 173 | + let response = client | |
| 174 | + .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 175 | + .header("Content-Type", "application/activity+json") | |
| 176 | + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 177 | + .send() | |
| 178 | + .await | |
| 179 | + .expect("request must succeed"); | |
| 180 | + assert!( | |
| 181 | + matches!( | |
| 182 | + response.status(), | |
| 183 | + StatusCode::UNAUTHORIZED | StatusCode::BAD_REQUEST | |
| 184 | + ), | |
| 185 | + "unsigned inbox POST must be 4xx, got {}", | |
| 186 | + response.status() | |
| 187 | + ); | |
| 188 | +} | |
| 189 | + | |
| 190 | +// ─────────────────────────────────────────────────────────────────────── | |
| 191 | +// Iteration 1b: S1 — SSO bilateral signed request, real HTTP, real crypto | |
| 192 | +// ─────────────────────────────────────────────────────────────────────── | |
| 193 | + | |
| 194 | +/// Beta's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/beta.env`. | |
| 195 | +/// Testbed-only: never reuse anywhere else. | |
| 196 | +const BETA_ENCRYPTION_KEY: &str = | |
| 197 | + "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"; | |
| 198 | + | |
| 199 | +/// Alpha's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/alpha.env`. | |
| 200 | +const ALPHA_ENCRYPTION_KEY: &str = | |
| 201 | + "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"; | |
| 202 | + | |
| 203 | +/// Absolute path to the testbed directory (compose file lives here). | |
| 204 | +const TESTBED_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/fediverse-testbed"); | |
| 205 | + | |
| 206 | +/// Run a SQL statement on a testbed instance via `docker compose exec psql`. | |
| 207 | +/// Returns trimmed stdout. Panics on non-zero exit. | |
| 208 | +fn psql(instance: &str, sql: &str) -> String { | |
| 209 | + let output = Command::new("docker") | |
| 210 | + .args([ | |
| 211 | + "compose", | |
| 212 | + "exec", | |
| 213 | + "-T", | |
| 214 | + &format!("postgres-{instance}"), | |
| 215 | + "psql", | |
| 216 | + "-U", | |
| 217 | + "rustwarden", | |
| 218 | + "-d", | |
| 219 | + &format!("rustwarden_{instance}"), | |
| 220 | + "-tA", | |
| 221 | + "-c", | |
| 222 | + sql, | |
| 223 | + ]) | |
| 224 | + .current_dir(TESTBED_DIR) | |
| 225 | + .output() | |
| 226 | + .expect("docker compose exec must be runnable"); | |
| 227 | + assert!( | |
| 228 | + output.status.success(), | |
| 229 | + "psql {instance} failed: {}", | |
| 230 | + String::from_utf8_lossy(&output.stderr) | |
| 231 | + ); | |
| 232 | + String::from_utf8_lossy(&output.stdout).trim().to_string() | |
| 233 | +} | |
| 234 | + | |
| 235 | +/// Reqwest client with custom DNS so that `http://alpha.local/...` connects | |
| 236 | +/// to `127.0.0.1:3001` while sending `Host: alpha.local`. Necessary because | |
| 237 | +/// the signed signing string must match the Host the server actually sees. | |
| 238 | +fn signing_client() -> Client { | |
| 239 | + Client::builder() | |
| 240 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 241 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 242 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 243 | + .timeout(std::time::Duration::from_secs(5)) | |
| 244 | + .build() | |
| 245 | + .expect("reqwest client builder must not fail") | |
| 246 | +} | |
| 247 | + | |
| 248 | +/// Build the three headers (Date, Host, Signature) that a signed POST to | |
| 249 | +/// `target_url` (e.g. `http://alpha.local/fediverse/auth/inbox`) must carry, | |
| 250 | +/// per draft-cavage with `(request-target) host date`. | |
| 251 | +/// | |
| 252 | +/// `actor_url` is the *non-fragmented* AP actor URL of the requester; the | |
| 253 | +/// `#main-key` fragment is appended automatically to match what | |
| 254 | +/// [`HttpSignedDelivery::deliver`] emits. | |
| 255 | +fn signed_headers( | |
| 256 | + private_pem: &str, | |
| 257 | + actor_url: &str, | |
| 258 | + target_url: &str, | |
| 259 | +) -> (String, String, String) { | |
| 260 | + let target = url::Url::parse(target_url).expect("target_url must parse"); | |
| 261 | + let host = target.host_str().expect("target_url must have host").to_string(); | |
| 262 | + let path = target.path().to_string(); | |
| 263 | + | |
| 264 | + let date = chrono::Utc::now() | |
| 265 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 266 | + .to_string(); | |
| 267 | + let signed = vec![ | |
| 268 | + "(request-target)".to_string(), | |
| 269 | + "host".to_string(), | |
| 270 | + "date".to_string(), | |
| 271 | + ]; | |
| 272 | + let request_target = format!("post {path}"); | |
| 273 | + | |
| 274 | + let host_for_input = host.clone(); | |
| 275 | + let date_for_input = date.clone(); | |
| 276 | + let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 277 | + "host" => Some(host_for_input.clone()), | |
| 278 | + "date" => Some(date_for_input.clone()), | |
| 279 | + _ => None, | |
| 280 | + }) | |
| 281 | + .expect("build_signature_input must succeed"); | |
| 282 | + | |
| 283 | + let sig_bytes = rsa_sha256_sign(private_pem, input.as_bytes()) | |
| 284 | + .expect("rsa_sha256_sign must succeed"); | |
| 285 | + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 286 | + | |
| 287 | + let sig_header = format!( | |
| 288 | + r#"keyId="{actor_url}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""# | |
| 289 | + ); | |
| 290 | + (date, host, sig_header) | |
| 291 | +} | |
| 292 | + | |
| 293 | +/// Material the test needs to act on behalf of joanie@beta against alpha. | |
| 294 | +struct JoanieBeta { | |
| 295 | + private_pem: String, | |
| 296 | + actor_url: String, | |
| 297 | +} | |
| 298 | + | |
| 299 | +/// Fetch joanie@beta's actor (which lazy-generates RSA keys server-side) and | |
| 300 | +/// extract her decrypted private PEM from beta's DB. | |
| 301 | +async fn joanie_beta_keys(client: &Client) -> JoanieBeta { | |
| 302 | + let actor: Value = client | |
| 303 | + .get(format!("{BETA}/users/joanie")) | |
| 304 | + .header("Accept", "application/activity+json") | |
| 305 | + .send() | |
| 306 | + .await | |
| 307 | + .expect("actor fetch must succeed") | |
| 308 | + .json() | |
| 309 | + .await | |
| 310 | + .expect("actor JSON must parse"); | |
| 311 | + let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 312 | + assert_eq!(actor_url, "http://beta.local/users/joanie"); | |
| 313 | + | |
| 314 | + let encrypted = psql( | |
| 315 | + "beta", | |
| 316 | + "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 317 | + ); | |
| 318 | + assert!(encrypted.starts_with("enc:")); | |
| 319 | + | |
| 320 | + let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 321 | + unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) }; | |
| 322 | + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed"); | |
| 323 | + unsafe { | |
| 324 | + match prev { | |
| 325 | + Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 326 | + None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 327 | + } | |
| 328 | + } | |
| 329 | + assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 330 | + | |
| 331 | + JoanieBeta { | |
| 332 | + private_pem, | |
| 333 | + actor_url, | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 337 | +/// Simulate the side-effect of the real signature verifier: insert a | |
| 338 | +/// `remote_identities` row on `target_instance` for the given remote actor. | |
| 339 | +/// The debug `AlwaysAcceptVerifier` used in the testbed lets requests through | |
| 340 | +/// without populating this cache, so the test does it explicitly. | |
| 341 | +fn upsert_remote_identity( | |
| 342 | + target_instance: &str, | |
| 343 | + actor_url: &str, | |
| 344 | + username: &str, | |
| 345 | + domain: &str, | |
| 346 | + inbox_url: &str, | |
| 347 | + public_pem: &str, | |
| 348 | +) { | |
| 349 | + // Escape single quotes so the PEM body is safe inside a SQL literal. | |
| 350 | + let pem_escaped = public_pem.replace('\'', "''"); | |
| 351 | + let sql = format!( | |
| 352 | + "INSERT INTO remote_identities | |
| 353 | + (id, ap_id, username, domain, inbox_url, public_key_pem, | |
| 354 | + actor_type, last_fetched_at, created_at, updated_at) | |
| 355 | + VALUES (gen_random_uuid(), '{actor_url}', '{username}', '{domain}', | |
| 356 | + '{inbox_url}', '{pem_escaped}', 'Person', NOW(), NOW(), NOW()) | |
| 357 | + ON CONFLICT (ap_id) DO UPDATE SET | |
| 358 | + public_key_pem = EXCLUDED.public_key_pem, | |
| 359 | + last_fetched_at = NOW(), | |
| 360 | + updated_at = NOW();" | |
| 361 | + ); | |
| 362 | + psql(target_instance, &sql); | |
| 363 | +} | |
| 364 | + | |
| 365 | +#[tokio::test] | |
| 366 | +async fn s1_signed_authrequest_yields_pending_challenge_on_alpha() { | |
| 367 | + let Some(_) = testbed_or_skip().await else { | |
| 368 | + return; | |
| 369 | + }; | |
| 370 | + let client = signing_client(); | |
| 371 | + | |
| 372 | + // 1. Get joanie@beta's keys (server-side generation + DB extraction). | |
| 373 | + let joanie = joanie_beta_keys(&client).await; | |
| 374 | + | |
| 375 | + // 2. (Finding F2 fix) — `AlwaysAcceptVerifier::with_resolver` now | |
| 376 | + // populates alpha's `remote_identities` cache on first sight of the | |
| 377 | + // actor as part of the inbox handling. Nothing to pre-seed here; | |
| 378 | + // the test exercises that exact contract. | |
| 379 | + | |
| 380 | + // 3. Snapshot alpha's pending challenge count. | |
| 381 | + let before: i64 = psql( | |
| 382 | + "alpha", | |
| 383 | + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 384 | + ) | |
| 385 | + .parse() | |
| 386 | + .expect("count must parse"); | |
| 387 | + | |
| 388 | + // 3. Build a fresh, valid AuthRequest body. | |
| 389 | + let nonce = uuid::Uuid::new_v4().to_string(); | |
| 390 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 391 | + let payload = json!({ | |
| 392 | + "type": "AuthRequest", | |
| 393 | + "actor": joanie.actor_url, | |
| 394 | + "target": "joanie@alpha.local", | |
| 395 | + "nonce": nonce, | |
| 396 | + "expiresAt": expires_at, | |
| 397 | + }); | |
| 398 | + let body_bytes = serde_json::to_vec(&payload).expect("payload must serialize"); | |
| 399 | + | |
| 400 | + // 4. Sign + POST. | |
| 401 | + let target_url = "http://alpha.local/fediverse/auth/inbox"; | |
| 402 | + let (date, host, sig) = signed_headers(&joanie.private_pem, &joanie.actor_url, target_url); | |
| 403 | + | |
| 404 | + let response = client | |
| 405 | + .post(target_url) | |
| 406 | + .header("Host", host) | |
| 407 | + .header("Date", date) | |
| 408 | + .header("Signature", sig) | |
| 409 | + .header("Content-Type", "application/activity+json") | |
| 410 | + .body(body_bytes) | |
| 411 | + .send() | |
| 412 | + .await | |
| 413 | + .expect("signed POST must complete"); | |
| 414 | + | |
| 415 | + let status = response.status(); | |
| 416 | + let body = response.text().await.unwrap_or_default(); | |
| 417 | + assert_eq!( | |
| 418 | + status, | |
| 419 | + StatusCode::ACCEPTED, | |
| 420 | + "alpha must accept the signed AuthRequest; got {status} body={body}" | |
| 421 | + ); | |
| 422 | + | |
| 423 | + // 5. Alpha must now know about joanie@beta as a remote identity. | |
| 424 | + let ri_count = psql( | |
| 425 | + "alpha", | |
| 426 | + "SELECT COUNT(*) FROM remote_identities WHERE ap_id='http://beta.local/users/joanie'", | |
| 427 | + ); | |
| 428 | + assert_eq!(ri_count, "1", "alpha must have 1 remote_identity for joanie@beta"); | |
| 429 | + | |
| 430 | + // 6. Alpha must have created a pending challenge for that nonce. | |
| 431 | + let after: i64 = psql( | |
| 432 | + "alpha", | |
| 433 | + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'", | |
| 434 | + ) | |
| 435 | + .parse() | |
| 436 | + .expect("count must parse"); | |
| 437 | + assert_eq!( | |
| 438 | + after, | |
| 439 | + before + 1, | |
| 440 | + "alpha must have exactly one more pending challenge after the POST" | |
| 441 | + ); | |
| 442 | + | |
| 443 | + let nonce_present = psql( | |
| 444 | + "alpha", | |
| 445 | + &format!("SELECT COUNT(*) FROM fediverse_auth_challenges WHERE nonce='{nonce}'"), | |
| 446 | + ); | |
| 447 | + assert_eq!(nonce_present, "1", "the inserted challenge must carry our nonce"); | |
| 448 | +} | |
| 449 | + | |
| 450 | +// ─────────────────────────────────────────────────────────────────────── | |
| 451 | +// Iteration 1d: F3 — full S1 round-trip via Accept(AuthRequest) (RFC 0025) | |
| 452 | +// ─────────────────────────────────────────────────────────────────────── | |
| 453 | + | |
| 454 | +/// Fetch joanie@alpha's keys (mirror of `joanie_beta_keys`). Used to | |
| 455 | +/// sign `Accept(AuthRequest)` activities pretending to come from | |
| 456 | +/// alpha's UI consent click. | |
| 457 | +async fn joanie_alpha_keys(client: &Client) -> JoanieBeta { | |
| 458 | + let actor: Value = client | |
| 459 | + .get(format!("{ALPHA}/users/joanie")) | |
| 460 | + .header("Accept", "application/activity+json") | |
| 461 | + .send() | |
| 462 | + .await | |
| 463 | + .expect("alpha actor fetch must succeed") | |
| 464 | + .json() | |
| 465 | + .await | |
| 466 | + .expect("alpha actor JSON must parse"); | |
| 467 | + let actor_url = actor["id"].as_str().unwrap().to_string(); | |
| 468 | + assert_eq!(actor_url, "http://alpha.local/users/joanie"); | |
| 469 | + | |
| 470 | + let encrypted = psql( | |
| 471 | + "alpha", | |
| 472 | + "SELECT ap_private_key_pem FROM users WHERE username='joanie'", | |
| 473 | + ); | |
| 474 | + assert!(encrypted.starts_with("enc:")); | |
| 475 | + | |
| 476 | + let prev = std::env::var("ENCRYPTION_KEY").ok(); | |
| 477 | + unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) }; | |
| 478 | + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed"); | |
| 479 | + unsafe { | |
| 480 | + match prev { | |
| 481 | + Some(v) => std::env::set_var("ENCRYPTION_KEY", v), | |
| 482 | + None => std::env::remove_var("ENCRYPTION_KEY"), | |
| 483 | + } | |
| 484 | + } | |
| 485 | + assert!(private_pem.contains("BEGIN PRIVATE KEY")); | |
| 486 | + JoanieBeta { | |
| 487 | + private_pem, | |
| 488 | + actor_url, | |
| 489 | + } | |
| 490 | +} | |
| 491 | + | |
| 492 | +/// Initiate an AuthRequest on B for the given handle (e.g. | |
| 493 | +/// `"joanie@alpha.local"`). Returns the B-local `challenge_id` and the | |
| 494 | +/// `nonce` that the cross-instance protocol uses as correlation token. | |
| 495 | +async fn initiate_on_beta(client: &Client, handle: &str) -> (Uuid, String) { | |
| 496 | + let body: Value = client | |
| 497 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 498 | + .json(&serde_json::json!({ "handle": handle })) | |
| 499 | + .send() | |
| 500 | + .await | |
| 501 | + .expect("initiate request must complete") | |
| 502 | + .json() | |
| 503 | + .await | |
| 504 | + .expect("initiate response must be JSON"); | |
| 505 | + let id = Uuid::parse_str(body["challenge_id"].as_str().unwrap()).unwrap(); | |
| 506 | + let nonce = body["nonce"].as_str().unwrap().to_string(); | |
| 507 | + (id, nonce) | |
| 508 | +} | |
| 509 | + | |
| 510 | +/// Build, sign, and POST an `Accept(AuthRequest)` or | |
| 511 | +/// `Reject(AuthRequest)` from joanie@alpha to beta's inbox. Returns the | |
| 512 | +/// raw HTTP response so callers can assert on status + body. | |
| 513 | +async fn send_decision_to_beta( | |
| 514 | + client: &Client, | |
| 515 | + alpha: &JoanieBeta, | |
| 516 | + nonce: &str, | |
| 517 | + approve: bool, | |
| 518 | +) -> reqwest::Response { | |
| 519 | + let activity_type = if approve { "Accept" } else { "Reject" }; | |
| 520 | + let payload = serde_json::json!({ | |
| 521 | + "@context": [ | |
| 522 | + "https://www.w3.org/ns/activitystreams", | |
| 523 | + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } | |
| 524 | + ], | |
| 525 | + "type": activity_type, | |
| 526 | + "id": format!("http://alpha.local/auth-decisions/{}", uuid::Uuid::new_v4()), | |
| 527 | + "actor": alpha.actor_url, | |
| 528 | + "object": { "type": "AuthRequest", "nonce": nonce }, | |
| 529 | + "to": ["http://beta.local/users/joanie/inbox"], | |
| 530 | + }); | |
| 531 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 532 | + | |
| 533 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 534 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 535 | + | |
| 536 | + client | |
| 537 | + .post(target_url) | |
| 538 | + .header("Host", host) | |
| 539 | + .header("Date", date) | |
| 540 | + .header("Signature", sig) | |
| 541 | + .header("Content-Type", "application/activity+json") | |
| 542 | + .body(body_bytes) | |
| 543 | + .send() | |
| 544 | + .await | |
| 545 | + .expect("decision POST must complete") | |
| 546 | +} | |
| 547 | + | |
| 548 | +#[tokio::test] | |
| 549 | +async fn s1_full_round_trip_accept_marks_b_challenge_approved() { | |
| 550 | + let Some(_) = testbed_or_skip().await else { | |
| 551 | + return; | |
| 552 | + }; | |
| 553 | + let client = signing_client(); | |
| 554 | + | |
| 555 | + // 1. B initiates: creates a Pending local challenge. | |
| 556 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 557 | + | |
| 558 | + // 2. Alpha keys (the approver clicking Approve on the IdP-side UI). | |
| 559 | + let alpha = joanie_alpha_keys(&client).await; | |
| 560 | + | |
| 561 | + // 3. Send signed Accept to B's inbox. | |
| 562 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ true).await; | |
| 563 | + let status = resp.status(); | |
| 564 | + let body = resp.text().await.unwrap_or_default(); | |
| 565 | + assert_eq!( | |
| 566 | + status, | |
| 567 | + StatusCode::ACCEPTED, | |
| 568 | + "beta must 202 a valid Accept; got {status} body={body}" | |
| 569 | + ); | |
| 570 | + | |
| 571 | + // 4. B's local challenge must now be Approved. | |
| 572 | + let row_status = psql( | |
| 573 | + "beta", | |
| 574 | + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 575 | + ); | |
| 576 | + assert_eq!(row_status, "approved", "challenge status mismatch"); | |
| 577 | + | |
| 578 | + // 5. B's consume must mint a remote JWT. | |
| 579 | + let consume = client | |
| 580 | + .post(format!( | |
| 581 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 582 | + )) | |
| 583 | + .send() | |
| 584 | + .await | |
| 585 | + .expect("consume must complete"); | |
| 586 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 587 | + let consume_body: Value = consume.json().await.unwrap(); | |
| 588 | + assert!(consume_body["jwt"].as_str().is_some()); | |
| 589 | +} | |
| 590 | + | |
| 591 | +#[tokio::test] | |
| 592 | +async fn s1_reject_propagates_and_blocks_consume() { | |
| 593 | + let Some(_) = testbed_or_skip().await else { | |
| 594 | + return; | |
| 595 | + }; | |
| 596 | + let client = signing_client(); | |
| 597 | + | |
| 598 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 599 | + let alpha = joanie_alpha_keys(&client).await; | |
| 600 | + | |
| 601 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ false).await; | |
| 602 | + assert_eq!(resp.status(), StatusCode::ACCEPTED); | |
| 603 | + | |
| 604 | + let row_status = psql( | |
| 605 | + "beta", | |
| 606 | + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 607 | + ); | |
| 608 | + assert_eq!(row_status, "rejected"); | |
| 609 | + | |
| 610 | + let consume = client | |
| 611 | + .post(format!( | |
| 612 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 613 | + )) | |
| 614 | + .send() | |
| 615 | + .await | |
| 616 | + .expect("consume must complete"); | |
| 617 | + assert!( | |
| 618 | + consume.status().is_client_error(), | |
| 619 | + "rejected challenge must yield 4xx on consume, got {}", | |
| 620 | + consume.status() | |
| 621 | + ); | |
| 622 | +} | |
| 623 | + | |
| 624 | +#[tokio::test] | |
| 625 | +async fn s1_unknown_nonce_is_rejected() { | |
| 626 | + let Some(_) = testbed_or_skip().await else { | |
| 627 | + return; | |
| 628 | + }; | |
| 629 | + let client = signing_client(); | |
| 630 | + let alpha = joanie_alpha_keys(&client).await; | |
| 631 | + | |
| 632 | + // Forge a fresh nonce that no B-side initiate has used. | |
| 633 | + let fake_nonce = format!("never-seen-{}", uuid::Uuid::new_v4()); | |
| 634 | + let resp = send_decision_to_beta(&client, &alpha, &fake_nonce, true).await; | |
| 635 | + let status = resp.status(); | |
| 636 | + assert!( | |
| 637 | + status.is_client_error(), | |
| 638 | + "unknown nonce must be refused, got {status}" | |
| 639 | + ); | |
| 640 | +} | |
| 641 | + | |
| 642 | +#[tokio::test] | |
| 643 | +async fn s1_expired_challenge_rejects_late_accept() { | |
| 644 | + let Some(_) = testbed_or_skip().await else { | |
| 645 | + return; | |
| 646 | + }; | |
| 647 | + let client = signing_client(); | |
| 648 | + | |
| 649 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 650 | + let alpha = joanie_alpha_keys(&client).await; | |
| 651 | + | |
| 652 | + // Manually backdate `expires_at` so the challenge looks expired. | |
| 653 | + psql( | |
| 654 | + "beta", | |
| 655 | + &format!( | |
| 656 | + "UPDATE fediverse_auth_challenges SET expires_at = NOW() - INTERVAL '1 hour' \ | |
| 657 | + WHERE id='{challenge_id}'" | |
| 658 | + ), | |
| 659 | + ); | |
| 660 | + | |
| 661 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 662 | + assert!( | |
| 663 | + resp.status().is_client_error(), | |
| 664 | + "expired challenge must reject Accept; got {}", | |
| 665 | + resp.status() | |
| 666 | + ); | |
| 667 | +} | |
| 668 | + | |
| 669 | +#[tokio::test] | |
| 670 | +async fn s1_replay_accept_is_idempotent() { | |
| 671 | + let Some(_) = testbed_or_skip().await else { | |
| 672 | + return; | |
| 673 | + }; | |
| 674 | + let client = signing_client(); | |
| 675 | + | |
| 676 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 677 | + let alpha = joanie_alpha_keys(&client).await; | |
| 678 | + | |
| 679 | + let r1 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 680 | + assert_eq!(r1.status(), StatusCode::ACCEPTED); | |
| 681 | + | |
| 682 | + // PostgreSQL system column `xmin` carries the transaction id that | |
| 683 | + // last wrote the row. If `record_inbound_decision` correctly bails | |
| 684 | + // out on a non-Pending challenge, xmin must be stable. | |
| 685 | + let xmin_1 = psql( | |
| 686 | + "beta", | |
| 687 | + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 688 | + ); | |
| 689 | + | |
| 690 | + let r2 = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 691 | + assert_eq!(r2.status(), StatusCode::ACCEPTED, "replay must still 202"); | |
| 692 | + | |
| 693 | + let xmin_2 = psql( | |
| 694 | + "beta", | |
| 695 | + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"), | |
| 696 | + ); | |
| 697 | + assert_eq!(xmin_1, xmin_2, "idempotent replay must not rewrite the row"); | |
| 698 | +} | |
| 699 | + | |
| 700 | +// ─────────────────────────────────────────────────────────────────────── | |
| 701 | +// Iteration 2a (S2) — Cross-instance team invitation, B-side flow. | |
| 702 | +// | |
| 703 | +// Validates the part of S2 that is operational without the missing | |
| 704 | +// AP-Invite back-channel (findings F4–F7 of the iter1 plan, hors-scope | |
| 705 | +// this iteration). Specifically: bob (admin on B) creates a federated | |
| 706 | +// invite for @joanie@alpha.local; joanie already has a remote_identity | |
| 707 | +// row on B (populated by S1's verifier); accepting the invite via the | |
| 708 | +// API attaches a team_member row bound to that remote identity. | |
| 709 | +// ─────────────────────────────────────────────────────────────────────── | |
| 710 | + | |
| 711 | +/// Run a one-shot S1 happy-path so joanie@alpha is cached in beta's | |
| 712 | +/// `remote_identities` before the invite tests need her. | |
| 713 | +async fn ensure_joanie_alpha_in_beta_cache(client: &Client) { | |
| 714 | + let (_challenge_id, nonce) = initiate_on_beta(client, "joanie@alpha.local").await; | |
| 715 | + let alpha = joanie_alpha_keys(client).await; | |
| 716 | + let _ = send_decision_to_beta(client, &alpha, &nonce, true).await; | |
| 717 | +} | |
| 718 | + | |
| 719 | +#[tokio::test] | |
| 720 | +async fn s2_admin_invite_to_remote_joanie_creates_team_membership() { | |
| 721 | + let Some(_) = testbed_or_skip().await else { | |
| 722 | + return; | |
| 723 | + }; | |
| 724 | + let client = signing_client(); | |
| 725 | + | |
| 726 | + // 0. Make sure beta knows joanie@alpha (S1 contract). | |
| 727 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 728 | + let joanie_alpha_rid = psql( | |
| 729 | + "beta", | |
| 730 | + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 731 | + ); | |
| 732 | + assert!(!joanie_alpha_rid.is_empty(), "joanie@alpha must be cached"); | |
| 733 | + | |
| 734 | + // 1. Create a team on beta. Slug must be unique across runs. | |
| 735 | + let team_slug = format!( | |
| 736 | + "team-s2-{}", | |
| 737 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 738 | + ); | |
| 739 | + psql( | |
| 740 | + "beta", | |
| 741 | + &format!( | |
| 742 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 743 | + VALUES (gen_random_uuid(), '{team_slug}', 'S2 Test Team', \ | |
| 744 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 745 | + ), | |
| 746 | + ); | |
| 747 | + let team_id = psql( | |
| 748 | + "beta", | |
| 749 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 750 | + ); | |
| 751 | + | |
| 752 | + // 2. Admin creates the invite via the API. | |
| 753 | + let invite_resp: Value = client | |
| 754 | + .post(format!( | |
| 755 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 756 | + )) | |
| 757 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 758 | + .send() | |
| 759 | + .await | |
| 760 | + .expect("invite POST must complete") | |
| 761 | + .json() | |
| 762 | + .await | |
| 763 | + .expect("invite response must be JSON"); | |
| 764 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 765 | + | |
| 766 | + // 3. Accept the invite, binding to joanie@alpha's remote_identity. | |
| 767 | + let accept = client | |
| 768 | + .post(format!( | |
| 769 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 770 | + )) | |
| 771 | + .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 772 | + .send() | |
| 773 | + .await | |
| 774 | + .expect("accept must complete"); | |
| 775 | + assert_eq!(accept.status(), StatusCode::OK, "accept must 200"); | |
| 776 | + | |
| 777 | + // 4. team_members row must exist with the remote_identity_id. | |
| 778 | + let member_count = psql( | |
| 779 | + "beta", | |
| 780 | + &format!( | |
| 781 | + "SELECT COUNT(*) FROM team_members \ | |
| 782 | + WHERE team_id='{team_id}' AND remote_identity_id='{joanie_alpha_rid}'" | |
| 783 | + ), | |
| 784 | + ); | |
| 785 | + assert_eq!(member_count, "1", "exactly 1 remote team_member expected"); | |
| 786 | + | |
| 787 | + // 5. Invite status must be `accepted`. | |
| 788 | + let invite_status = psql( | |
| 789 | + "beta", | |
| 790 | + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 791 | + ); | |
| 792 | + assert_eq!(invite_status, "accepted"); | |
| 793 | +} | |
| 794 | + | |
| 795 | +#[tokio::test] | |
| 796 | +async fn s2_invite_reject_flips_status_to_rejected_no_member() { | |
| 797 | + let Some(_) = testbed_or_skip().await else { | |
| 798 | + return; | |
| 799 | + }; | |
| 800 | + let client = signing_client(); | |
| 801 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 802 | + | |
| 803 | + let team_slug = format!( | |
| 804 | + "team-s2-rej-{}", | |
| 805 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 806 | + ); | |
| 807 | + psql( | |
| 808 | + "beta", | |
| 809 | + &format!( | |
| 810 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 811 | + VALUES (gen_random_uuid(), '{team_slug}', 'Rej Team', \ | |
| 812 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 813 | + ), | |
| 814 | + ); | |
| 815 | + let team_id = psql( | |
| 816 | + "beta", | |
| 817 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 818 | + ); | |
| 819 | + | |
| 820 | + let invite_resp: Value = client | |
| 821 | + .post(format!( | |
| 822 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 823 | + )) | |
| 824 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 825 | + .send() | |
| 826 | + .await | |
| 827 | + .unwrap() | |
| 828 | + .json() | |
| 829 | + .await | |
| 830 | + .unwrap(); | |
| 831 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 832 | + | |
| 833 | + let reject = client | |
| 834 | + .post(format!( | |
| 835 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/reject" | |
| 836 | + )) | |
| 837 | + .send() | |
| 838 | + .await | |
| 839 | + .unwrap(); | |
| 840 | + assert_eq!(reject.status(), StatusCode::OK); | |
| 841 | + | |
| 842 | + let invite_status = psql( | |
| 843 | + "beta", | |
| 844 | + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"), | |
| 845 | + ); | |
| 846 | + assert_eq!(invite_status, "rejected"); | |
| 847 | + | |
| 848 | + let member_count = psql( | |
| 849 | + "beta", | |
| 850 | + &format!("SELECT COUNT(*) FROM team_members WHERE team_id='{team_id}'"), | |
| 851 | + ); | |
| 852 | + assert_eq!(member_count, "0", "rejection must NOT add a member"); | |
| 853 | +} | |
| 854 | + | |
| 855 | +#[tokio::test] | |
| 856 | +async fn s2_invite_revoke_blocks_subsequent_accept() { | |
| 857 | + let Some(_) = testbed_or_skip().await else { | |
| 858 | + return; | |
| 859 | + }; | |
| 860 | + let client = signing_client(); | |
| 861 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 862 | + let joanie_alpha_rid = psql( | |
| 863 | + "beta", | |
| 864 | + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'", | |
| 865 | + ); | |
| 866 | + | |
| 867 | + let team_slug = format!( | |
| 868 | + "team-s2-rev-{}", | |
| 869 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 870 | + ); | |
| 871 | + psql( | |
| 872 | + "beta", | |
| 873 | + &format!( | |
| 874 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 875 | + VALUES (gen_random_uuid(), '{team_slug}', 'Rev Team', \ | |
| 876 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 877 | + ), | |
| 878 | + ); | |
| 879 | + let team_id = psql( | |
| 880 | + "beta", | |
| 881 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 882 | + ); | |
| 883 | + | |
| 884 | + let invite_resp: Value = client | |
| 885 | + .post(format!( | |
| 886 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 887 | + )) | |
| 888 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 889 | + .send() | |
| 890 | + .await | |
| 891 | + .unwrap() | |
| 892 | + .json() | |
| 893 | + .await | |
| 894 | + .unwrap(); | |
| 895 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 896 | + | |
| 897 | + // Admin revokes the invite before joanie accepts. | |
| 898 | + let revoke = client | |
| 899 | + .post(format!( | |
| 900 | + "{BETA}/api/v1/teams/{team_id}/invites/{invite_id}/revoke" | |
| 901 | + )) | |
| 902 | + .send() | |
| 903 | + .await | |
| 904 | + .unwrap(); | |
| 905 | + assert_eq!(revoke.status(), StatusCode::OK); | |
| 906 | + | |
| 907 | + // Joanie's late acceptance must now fail (state != Pending). | |
| 908 | + let accept = client | |
| 909 | + .post(format!( | |
| 910 | + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept" | |
| 911 | + )) | |
| 912 | + .json(&json!({ "remote_identity_id": joanie_alpha_rid })) | |
| 913 | + .send() | |
| 914 | + .await | |
| 915 | + .unwrap(); | |
| 916 | + assert!( | |
| 917 | + accept.status().is_client_error(), | |
| 918 | + "revoked invite must reject Accept, got {}", | |
| 919 | + accept.status() | |
| 920 | + ); | |
| 921 | +} | |
| 922 | + | |
| 923 | +#[tokio::test] | |
| 924 | +async fn s2_idp_list_returns_pending_invite_for_handle() { | |
| 925 | + let Some(_) = testbed_or_skip().await else { | |
| 926 | + return; | |
| 927 | + }; | |
| 928 | + let client = signing_client(); | |
| 929 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 930 | + | |
| 931 | + let team_slug = format!( | |
| 932 | + "team-s2-list-{}", | |
| 933 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 934 | + ); | |
| 935 | + psql( | |
| 936 | + "beta", | |
| 937 | + &format!( | |
| 938 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 939 | + VALUES (gen_random_uuid(), '{team_slug}', 'List Team', \ | |
| 940 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 941 | + ), | |
| 942 | + ); | |
| 943 | + let team_id = psql( | |
| 944 | + "beta", | |
| 945 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 946 | + ); | |
| 947 | + | |
| 948 | + let invite_resp: Value = client | |
| 949 | + .post(format!( | |
| 950 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 951 | + )) | |
| 952 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "admin" })) | |
| 953 | + .send() | |
| 954 | + .await | |
| 955 | + .unwrap() | |
| 956 | + .json() | |
| 957 | + .await | |
| 958 | + .unwrap(); | |
| 959 | + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string(); | |
| 960 | + | |
| 961 | + // IdP-side listing: beta's own /api/v1/fediverse/invites endpoint | |
| 962 | + // returns invites pending for a given handle. | |
| 963 | + let listed: Value = client | |
| 964 | + .get(format!("{BETA}/api/v1/fediverse/invites")) | |
| 965 | + .query(&[("handle", "joanie@alpha.local")]) | |
| 966 | + .send() | |
| 967 | + .await | |
| 968 | + .expect("list must complete") | |
| 969 | + .json() | |
| 970 | + .await | |
| 971 | + .expect("list response must be JSON"); | |
| 972 | + let arr = listed.as_array().expect("list must be array"); | |
| 973 | + let found = arr.iter().any(|row| row["id"].as_str() == Some(&invite_id)); | |
| 974 | + assert!(found, "freshly-created invite must appear in the listing"); | |
| 975 | +} | |
| 976 | + | |
| 977 | +// ─────────────────────────────────────────────────────────────────────── | |
| 978 | +// Iteration 3a (S3) — Trust model & non-transitivity | |
| 979 | +// | |
| 980 | +// Scenario from plan parent §S3 : | |
| 981 | +// A trust B uniquement | |
| 982 | +// B trust A and C | |
| 983 | +// C trust B uniquement | |
| 984 | +// A does NOT trust C, and C is not in A's blocklist either. | |
| 985 | +// | |
| 986 | +// Property under test : an AP activity signed by some domain D may | |
| 987 | +// only enrich A's protocol state in ways scoped to D itself — it MUST | |
| 988 | +// NOT cause A to add D (or anyone else) to `fediverse_trusted_domains` | |
| 989 | +// automatically. The trust list is **administrator-only** input. | |
| 990 | +// | |
| 991 | +// **F8 (bloquant pour promotion stable)** — the protocolar routes | |
| 992 | +// (auth_routes, inbox, invite_routes) never consult | |
| 993 | +// `FediverseTrustService`. Trust list enforcement is decorative | |
| 994 | +// (UI-only). A hostile but signature-valid instance can today push | |
| 995 | +// AP activities into another instance regardless of its trust state. | |
| 996 | +// The test `s3_finding_f8_untrusted_domain_payload_accepted` | |
| 997 | +// reproduces the bug objectively. | |
| 998 | +// ─────────────────────────────────────────────────────────────────────── | |
| 999 | + | |
| 1000 | +#[tokio::test] | |
| 1001 | +async fn s3_signed_protocol_flow_never_writes_trust_list() { | |
| 1002 | + let Some(_) = testbed_or_skip().await else { | |
| 1003 | + return; | |
| 1004 | + }; | |
| 1005 | + let client = signing_client(); | |
| 1006 | + | |
| 1007 | + // Snapshot beta's trust list before any protocol exchange. | |
| 1008 | + let before = psql( | |
| 1009 | + "beta", | |
| 1010 | + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1011 | + ); | |
| 1012 | + | |
| 1013 | + // Drive a complete signed S1 flow (which exercises auth_inbox + | |
| 1014 | + // F2's fetch_and_cache_remote_actor + F3's record_inbound_decision) | |
| 1015 | + // and a signed S2 invite flow. | |
| 1016 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1017 | + let team_slug = format!( | |
| 1018 | + "team-s3-trust-{}", | |
| 1019 | + Uuid::new_v4().to_string().chars().take(8).collect::<String>() | |
| 1020 | + ); | |
| 1021 | + psql( | |
| 1022 | + "beta", | |
| 1023 | + &format!( | |
| 1024 | + "INSERT INTO teams (id, slug, name, owner_id) \ | |
| 1025 | + VALUES (gen_random_uuid(), '{team_slug}', 'S3 Trust Team', \ | |
| 1026 | + (SELECT id FROM users WHERE username='joanie'))" | |
| 1027 | + ), | |
| 1028 | + ); | |
| 1029 | + let team_id = psql( | |
| 1030 | + "beta", | |
| 1031 | + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"), | |
| 1032 | + ); | |
| 1033 | + let _ = client | |
| 1034 | + .post(format!( | |
| 1035 | + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse" | |
| 1036 | + )) | |
| 1037 | + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" })) | |
| 1038 | + .send() | |
| 1039 | + .await | |
| 1040 | + .unwrap(); | |
| 1041 | + | |
| 1042 | + // After all that, the trust list must be byte-identical: protocol | |
| 1043 | + // exchanges do not promote anyone to trusted. | |
| 1044 | + let after = psql( | |
| 1045 | + "beta", | |
| 1046 | + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1047 | + ); | |
| 1048 | + assert_eq!( | |
| 1049 | + before, after, | |
| 1050 | + "fediverse_trusted_domains must be untouched by protocol-level activity" | |
| 1051 | + ); | |
| 1052 | +} | |
| 1053 | + | |
| 1054 | +#[tokio::test] | |
| 1055 | +async fn s3_untrusted_domain_is_rejected_with_403() { | |
| 1056 | + // F8 fix : beta's auth_inbox now consults | |
| 1057 | + // FediverseTrustService::assert_domain_allowed and refuses 403 | |
| 1058 | + // (FediverseError::Forbidden) when the source domain is not in | |
| 1059 | + // beta's allowlist. | |
| 1060 | + | |
| 1061 | + let Some(_) = testbed_or_skip().await else { | |
| 1062 | + return; | |
| 1063 | + }; | |
| 1064 | + let client = signing_client(); | |
| 1065 | + | |
| 1066 | + // 0. Flip beta to allowlist mode. The testbed boots with | |
| 1067 | + // `fediverse_trust_mode = open_with_blocklist` (Mastodon-style : | |
| 1068 | + // accept everyone except blocked domains). F8 enforcement on the | |
| 1069 | + // trust list only kicks in when the mode is `allowlist`, so we | |
| 1070 | + // explicitly switch it here and restore it before asserting. | |
| 1071 | + psql( | |
| 1072 | + "beta", | |
| 1073 | + "UPDATE app_settings SET value='allowlist' WHERE key='fediverse_trust_mode'", | |
| 1074 | + ); | |
| 1075 | + | |
| 1076 | + // 1. Remove alpha from beta's trust list. | |
| 1077 | + psql( | |
| 1078 | + "beta", | |
| 1079 | + "DELETE FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1080 | + ); | |
| 1081 | + let trust_count = psql( | |
| 1082 | + "beta", | |
| 1083 | + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1084 | + ); | |
| 1085 | + assert_eq!(trust_count, "0", "alpha must be removed from beta's trust list"); | |
| 1086 | + | |
| 1087 | + // 2. Sign and send an AuthRequest from joanie@alpha to beta. | |
| 1088 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1089 | + let nonce = Uuid::new_v4().to_string(); | |
| 1090 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1091 | + let payload = json!({ | |
| 1092 | + "type": "AuthRequest", | |
| 1093 | + "actor": alpha.actor_url, | |
| 1094 | + "target": "joanie@beta.local", | |
| 1095 | + "nonce": nonce, | |
| 1096 | + "expiresAt": expires_at, | |
| 1097 | + }); | |
| 1098 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1099 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1100 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1101 | + | |
| 1102 | + let resp = client | |
| 1103 | + .post(target_url) | |
| 1104 | + .header("Host", host) | |
| 1105 | + .header("Date", date) | |
| 1106 | + .header("Signature", sig) | |
| 1107 | + .header("Content-Type", "application/activity+json") | |
| 1108 | + .body(body_bytes) | |
| 1109 | + .send() | |
| 1110 | + .await | |
| 1111 | + .expect("POST must complete"); | |
| 1112 | + | |
| 1113 | + let status = resp.status(); | |
| 1114 | + // Restore mode + trust list FIRST to avoid poisoning other tests on failure path. | |
| 1115 | + psql( | |
| 1116 | + "beta", | |
| 1117 | + "UPDATE app_settings SET value='open_with_blocklist' WHERE key='fediverse_trust_mode'", | |
| 1118 | + ); | |
| 1119 | + psql( | |
| 1120 | + "beta", | |
| 1121 | + "INSERT INTO fediverse_trusted_domains (id, domain, note, created_at) \ | |
| 1122 | + VALUES (gen_random_uuid(), 'alpha.local', 'restore after S3 test', NOW()) \ | |
| 1123 | + ON CONFLICT (domain) DO NOTHING", | |
| 1124 | + ); | |
| 1125 | + | |
| 1126 | + assert_eq!( | |
| 1127 | + status, | |
| 1128 | + StatusCode::FORBIDDEN, | |
| 1129 | + "F8 enforcement: untrusted source must receive 403; got {status}" | |
| 1130 | + ); | |
| 1131 | +} | |
| 1132 | + | |
| 1133 | +#[tokio::test] | |
| 1134 | +async fn s3_trusted_domain_still_passes_protocol_inbox() { | |
| 1135 | + // Positive regression for F8 : while the trust list contains alpha | |
| 1136 | + // (default after setup.sh + configure-trust.sh), signed activities | |
| 1137 | + // from alpha must still 202. | |
| 1138 | + let Some(_) = testbed_or_skip().await else { | |
| 1139 | + return; | |
| 1140 | + }; | |
| 1141 | + let client = signing_client(); | |
| 1142 | + | |
| 1143 | + // Confirm alpha is in beta's trust list (configure-trust.sh state). | |
| 1144 | + let present = psql( | |
| 1145 | + "beta", | |
| 1146 | + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'", | |
| 1147 | + ); | |
| 1148 | + assert_eq!(present, "1", "alpha must be in beta's trust list"); | |
| 1149 | + | |
| 1150 | + let (_challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1151 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1152 | + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1153 | + assert_eq!( | |
| 1154 | + resp.status(), | |
| 1155 | + StatusCode::ACCEPTED, | |
| 1156 | + "trusted domain must continue to be accepted" | |
| 1157 | + ); | |
| 1158 | +} | |
| 1159 | + | |
| 1160 | +#[tokio::test] | |
| 1161 | +async fn s3_trust_list_entries_persist_independent_of_protocol_activity() { | |
| 1162 | + let Some(_) = testbed_or_skip().await else { | |
| 1163 | + return; | |
| 1164 | + }; | |
| 1165 | + let client = signing_client(); | |
| 1166 | + | |
| 1167 | + // Both pre-existing trust entries are present (configure-trust.sh | |
| 1168 | + // bootstrap). Hammer the protocol with a signed flow and assert the | |
| 1169 | + // exact same rows remain — not merely a count check. | |
| 1170 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1171 | + | |
| 1172 | + let beta_trust = psql( | |
| 1173 | + "beta", | |
| 1174 | + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1175 | + ); | |
| 1176 | + let gamma_trust = psql( | |
| 1177 | + "gamma", | |
| 1178 | + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains", | |
| 1179 | + ); | |
| 1180 | + assert_eq!( | |
| 1181 | + beta_trust, "alpha.local,gamma.local", | |
| 1182 | + "beta's trust list must remain alpha+gamma — no auto-mutation" | |
| 1183 | + ); | |
| 1184 | + assert_eq!( | |
| 1185 | + gamma_trust, "alpha.local,beta.local", | |
| 1186 | + "gamma's trust list must remain alpha+beta — non-transitivity preserved" | |
| 1187 | + ); | |
| 1188 | +} | |
| 1189 | + | |
| 1190 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1191 | +// Iteration 4 (S4) — Block list + révocation en masse | |
| 1192 | +// | |
| 1193 | +// Per plan §S4, admin must be able to "cut" a hostile instance with | |
| 1194 | +// immediate effect : future signed POSTs rejected AND existing remote | |
| 1195 | +// JWT sessions invalidated. | |
| 1196 | +// | |
| 1197 | +// Validation covers what works as-is via direct DB / API drive ; surfaces | |
| 1198 | +// **F9** (add_blocked_domain doesn't auto-kill remote_identities) and | |
| 1199 | +// **F10** (kill_domain doesn't blacklist JWT) as findings. | |
| 1200 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1201 | + | |
| 1202 | +/// Switch beta's trust mode to `open_with_blocklist` for the duration of | |
| 1203 | +/// a S4 test, returning the previous value so it can be restored. | |
| 1204 | +fn set_beta_open_with_blocklist() -> Option<String> { | |
| 1205 | + let previous = psql( | |
| 1206 | + "beta", | |
| 1207 | + "SELECT value FROM app_settings WHERE key='fediverse_trust_mode'", | |
| 1208 | + ); | |
| 1209 | + psql( | |
| 1210 | + "beta", | |
| 1211 | + "INSERT INTO app_settings (id, key, value, description, updated_at) \ | |
| 1212 | + VALUES (gen_random_uuid(), 'fediverse_trust_mode', 'open_with_blocklist', NULL, NOW()) \ | |
| 1213 | + ON CONFLICT (key) DO UPDATE SET value='open_with_blocklist', updated_at=NOW()", | |
| 1214 | + ); | |
| 1215 | + if previous.is_empty() { None } else { Some(previous) } | |
| 1216 | +} | |
| 1217 | + | |
| 1218 | +fn restore_beta_trust_mode(previous: Option<String>) { | |
| 1219 | + let value = previous.unwrap_or_else(|| "allowlist".to_string()); | |
| 1220 | + psql( | |
| 1221 | + "beta", | |
| 1222 | + &format!( | |
| 1223 | + "UPDATE app_settings SET value='{value}', updated_at=NOW() \ | |
| 1224 | + WHERE key='fediverse_trust_mode'" | |
| 1225 | + ), | |
| 1226 | + ); | |
| 1227 | +} | |
| 1228 | + | |
| 1229 | +#[tokio::test] | |
| 1230 | +async fn s4_blocked_domain_payload_is_rejected_with_403() { | |
| 1231 | + let Some(_) = testbed_or_skip().await else { | |
| 1232 | + return; | |
| 1233 | + }; | |
| 1234 | + let client = signing_client(); | |
| 1235 | + | |
| 1236 | + let prev = set_beta_open_with_blocklist(); | |
| 1237 | + // Add alpha.local to beta's blocklist. | |
| 1238 | + psql( | |
| 1239 | + "beta", | |
| 1240 | + "INSERT INTO fediverse_blocked_domains \ | |
| 1241 | + (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1242 | + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 's4-test', NULL, NOW()) \ | |
| 1243 | + ON CONFLICT (domain) DO NOTHING", | |
| 1244 | + ); | |
| 1245 | + | |
| 1246 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1247 | + let nonce = Uuid::new_v4().to_string(); | |
| 1248 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1249 | + let payload = json!({ | |
| 1250 | + "type": "AuthRequest", | |
| 1251 | + "actor": alpha.actor_url, | |
| 1252 | + "target": "joanie@beta.local", | |
| 1253 | + "nonce": nonce, | |
| 1254 | + "expiresAt": expires_at, | |
| 1255 | + }); | |
| 1256 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1257 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1258 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1259 | + | |
| 1260 | + let resp = client | |
| 1261 | + .post(target_url) | |
| 1262 | + .header("Host", host) | |
| 1263 | + .header("Date", date) | |
| 1264 | + .header("Signature", sig) | |
| 1265 | + .header("Content-Type", "application/activity+json") | |
| 1266 | + .body(body_bytes) | |
| 1267 | + .send() | |
| 1268 | + .await | |
| 1269 | + .expect("POST must complete"); | |
| 1270 | + | |
| 1271 | + let status = resp.status(); | |
| 1272 | + | |
| 1273 | + // Clean up state regardless of assertion outcome. | |
| 1274 | + psql( | |
| 1275 | + "beta", | |
| 1276 | + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'", | |
| 1277 | + ); | |
| 1278 | + restore_beta_trust_mode(prev); | |
| 1279 | + | |
| 1280 | + assert_eq!( | |
| 1281 | + status, | |
| 1282 | + StatusCode::FORBIDDEN, | |
| 1283 | + "blocklist must produce 403 in OpenWithBlocklist mode; got {status}" | |
| 1284 | + ); | |
| 1285 | +} | |
| 1286 | + | |
| 1287 | +#[tokio::test] | |
| 1288 | +async fn s4_kill_domain_marks_remote_identities_disabled() { | |
| 1289 | + let Some(_) = testbed_or_skip().await else { | |
| 1290 | + return; | |
| 1291 | + }; | |
| 1292 | + let client = signing_client(); | |
| 1293 | + | |
| 1294 | + // Ensure joanie@alpha exists in beta's cache (S1 flow seeds it). | |
| 1295 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1296 | + let active_before = psql( | |
| 1297 | + "beta", | |
| 1298 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1299 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1300 | + ); | |
| 1301 | + assert!( | |
| 1302 | + active_before.parse::<i64>().unwrap() >= 1, | |
| 1303 | + "expected at least one active row to kill" | |
| 1304 | + ); | |
| 1305 | + | |
| 1306 | + // Bulk disable. | |
| 1307 | + psql( | |
| 1308 | + "beta", | |
| 1309 | + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1310 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1311 | + ); | |
| 1312 | + | |
| 1313 | + let active_after = psql( | |
| 1314 | + "beta", | |
| 1315 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1316 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1317 | + ); | |
| 1318 | + assert_eq!(active_after, "0", "no active row must remain for alpha.local"); | |
| 1319 | + | |
| 1320 | + // Restore for follow-on tests. | |
| 1321 | + psql( | |
| 1322 | + "beta", | |
| 1323 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1324 | + WHERE domain='alpha.local'", | |
| 1325 | + ); | |
| 1326 | +} | |
| 1327 | + | |
| 1328 | +#[tokio::test] | |
| 1329 | +async fn s4_kill_domain_invalidates_active_remote_jwt() { | |
| 1330 | + // F10 fix : remote JWTs are rejected at validation time when their | |
| 1331 | + // backing `remote_identities` row was disabled (kill_domain, F9 | |
| 1332 | + // auto-kill, or revoke). No `jwt_blacklist` insertion needed — the | |
| 1333 | + // disabled_at check is a cheap PK lookup in `AuthService`. | |
| 1334 | + | |
| 1335 | + let Some(_) = testbed_or_skip().await else { | |
| 1336 | + return; | |
| 1337 | + }; | |
| 1338 | + let client = signing_client(); | |
| 1339 | + | |
| 1340 | + // 1. Full S1 round-trip → fresh remote JWT for joanie@alpha on beta. | |
| 1341 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1342 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1343 | + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1344 | + let consume = client | |
| 1345 | + .post(format!( | |
| 1346 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1347 | + )) | |
| 1348 | + .send() | |
| 1349 | + .await | |
| 1350 | + .unwrap(); | |
| 1351 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 1352 | + let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1353 | + .as_str() | |
| 1354 | + .unwrap() | |
| 1355 | + .to_string(); | |
| 1356 | + | |
| 1357 | + // 2. Use the JWT to access a protected endpoint BEFORE kill_domain: | |
| 1358 | + // must redirect to /home (via auth middleware passing through). | |
| 1359 | + // We hit /profile which requires auth. | |
| 1360 | + let no_redirect_client = reqwest::Client::builder() | |
| 1361 | + .redirect(reqwest::redirect::Policy::none()) | |
| 1362 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1363 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1364 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1365 | + .timeout(std::time::Duration::from_secs(5)) | |
| 1366 | + .build() | |
| 1367 | + .unwrap(); | |
| 1368 | + let before = no_redirect_client | |
| 1369 | + .get("http://beta.local/profile/connected_apps") | |
| 1370 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1371 | + .send() | |
| 1372 | + .await | |
| 1373 | + .unwrap(); | |
| 1374 | + let before_location = before | |
| 1375 | + .headers() | |
| 1376 | + .get("location") | |
| 1377 | + .and_then(|v| v.to_str().ok()) | |
| 1378 | + .map(str::to_string); | |
| 1379 | + let before_redirects_login = before_location | |
| 1380 | + .as_deref() | |
| 1381 | + .is_some_and(|s| s.contains("/login")); | |
| 1382 | + // Pre-kill: middleware must NOT redirect to /login. It may render | |
| 1383 | + // /profile (200) or redirect within the app (e.g. /home) — anything | |
| 1384 | + // that's not "kicked back to /login" is acceptable. | |
| 1385 | + assert!( | |
| 1386 | + !before_redirects_login, | |
| 1387 | + "pre-kill response should NOT be redirected to /login; got status {} location {:?}", | |
| 1388 | + before.status(), | |
| 1389 | + before_location | |
| 1390 | + ); | |
| 1391 | + | |
| 1392 | + // 3. Kill alpha.local on beta. | |
| 1393 | + psql( | |
| 1394 | + "beta", | |
| 1395 | + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1396 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1397 | + ); | |
| 1398 | + // Sanity check — disabled_at must really be set. | |
| 1399 | + let disabled_count = psql( | |
| 1400 | + "beta", | |
| 1401 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1402 | + WHERE domain='alpha.local' AND disabled_at IS NOT NULL", | |
| 1403 | + ); | |
| 1404 | + assert!( | |
| 1405 | + disabled_count.parse::<i64>().unwrap() >= 1, | |
| 1406 | + "expected at least one disabled row, got {disabled_count}" | |
| 1407 | + ); | |
| 1408 | + | |
| 1409 | + // 4. Same request, post-kill: the JWT must now be rejected. | |
| 1410 | + let after = no_redirect_client | |
| 1411 | + .get("http://beta.local/profile/connected_apps") | |
| 1412 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1413 | + .send() | |
| 1414 | + .await | |
| 1415 | + .unwrap(); | |
| 1416 | + let after_location = after | |
| 1417 | + .headers() | |
| 1418 | + .get("location") | |
| 1419 | + .and_then(|v| v.to_str().ok()) | |
| 1420 | + .map(str::to_string); | |
| 1421 | + | |
| 1422 | + // Restore state so subsequent tests aren't poisoned. | |
| 1423 | + psql( | |
| 1424 | + "beta", | |
| 1425 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1426 | + WHERE domain='alpha.local'", | |
| 1427 | + ); | |
| 1428 | + | |
| 1429 | + let redirected_to_login = after_location | |
| 1430 | + .as_deref() | |
| 1431 | + .is_some_and(|s| s.contains("/login")); | |
| 1432 | + assert!( | |
| 1433 | + after.status().is_redirection() && redirected_to_login, | |
| 1434 | + "post-kill request must redirect to /login; got status {} location {:?}", | |
| 1435 | + after.status(), | |
| 1436 | + after_location | |
| 1437 | + ); | |
| 1438 | +} | |
| 1439 | + | |
| 1440 | +#[tokio::test] | |
| 1441 | +async fn s4_add_blocked_domain_auto_kills_remote_identities() { | |
| 1442 | + // F9 fix : adding a domain to the blocklist (via the admin handler) | |
| 1443 | + // must atomically also disable all matching `remote_identities`. | |
| 1444 | + // Drives the admin endpoint via a direct service-layer simulation | |
| 1445 | + // (this test exercises the DB consequences ; HTTP-level admin auth | |
| 1446 | + // requires a logged-in admin session which is out of E2E scope). | |
| 1447 | + | |
| 1448 | + let Some(_) = testbed_or_skip().await else { | |
| 1449 | + return; | |
| 1450 | + }; | |
| 1451 | + let client = signing_client(); | |
| 1452 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1453 | + | |
| 1454 | + let active_before = psql( | |
| 1455 | + "beta", | |
| 1456 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1457 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1458 | + ); | |
| 1459 | + assert!( | |
| 1460 | + active_before.parse::<i64>().unwrap() >= 1, | |
| 1461 | + "S4 setup: at least 1 active alpha.local identity expected" | |
| 1462 | + ); | |
| 1463 | + | |
| 1464 | + // Simulate what the admin handler now does atomically: | |
| 1465 | + // INSERT blocked_domains + UPDATE remote_identities disabled_at. | |
| 1466 | + let prev = set_beta_open_with_blocklist(); | |
| 1467 | + psql( | |
| 1468 | + "beta", | |
| 1469 | + "INSERT INTO fediverse_blocked_domains \ | |
| 1470 | + (id, domain, severity, reason, added_by_user_id, created_at) \ | |
| 1471 | + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 'f9-test', NULL, NOW()) \ | |
| 1472 | + ON CONFLICT (domain) DO NOTHING; \ | |
| 1473 | + UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \ | |
| 1474 | + WHERE domain='alpha.local' AND disabled_at IS NULL;", | |
| 1475 | + ); | |
| 1476 | + | |
| 1477 | + let active_after = psql( | |
| 1478 | + "beta", | |
| 1479 | + "SELECT COUNT(*) FROM remote_identities \ | |
| 1480 | + WHERE domain='alpha.local' AND disabled_at IS NULL", | |
| 1481 | + ); | |
| 1482 | + | |
| 1483 | + // Restore state for downstream tests. | |
| 1484 | + psql( | |
| 1485 | + "beta", | |
| 1486 | + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'; \ | |
| 1487 | + UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1488 | + WHERE domain='alpha.local';", | |
| 1489 | + ); | |
| 1490 | + restore_beta_trust_mode(prev); | |
| 1491 | + | |
| 1492 | + assert_eq!( | |
| 1493 | + active_after, "0", | |
| 1494 | + "after add_blocked_domain (F9), no active remote_identity for that domain should remain" | |
| 1495 | + ); | |
| 1496 | +} | |
| 1497 | + | |
| 1498 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1499 | +// Iteration 5 (S5) — Failure modes / negative scenarios | |
| 1500 | +// | |
| 1501 | +// Covers explicit failure paths from the parent plan §S5 : | |
| 1502 | +// - AuthRequest replay (same nonce twice → second rejected) | |
| 1503 | +// - WebFinger 404 for unknown user | |
| 1504 | +// - Initiate rejects malformed handle | |
| 1505 | +// - Date skew on signed request (finding F11: AlwaysAcceptVerifier | |
| 1506 | +// doesn't enforce Date window, only HttpSigVerifier does) | |
| 1507 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1508 | + | |
| 1509 | +#[tokio::test] | |
| 1510 | +async fn s5_replay_authrequest_with_same_nonce_rejected() { | |
| 1511 | + // `fediverse_auth_challenges.nonce` has a UNIQUE constraint; replaying | |
| 1512 | + // the same signed AuthRequest must fail (DB conflict → 5xx via | |
| 1513 | + // AppError::Internal). The protocol still rejects, satisfying the | |
| 1514 | + // anti-replay requirement of plan §S5. | |
| 1515 | + let Some(_) = testbed_or_skip().await else { | |
| 1516 | + return; | |
| 1517 | + }; | |
| 1518 | + let client = signing_client(); | |
| 1519 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1520 | + | |
| 1521 | + let nonce = Uuid::new_v4().to_string(); | |
| 1522 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1523 | + let payload = json!({ | |
| 1524 | + "type": "AuthRequest", | |
| 1525 | + "actor": alpha.actor_url, | |
| 1526 | + "target": "joanie@beta.local", | |
| 1527 | + "nonce": nonce, | |
| 1528 | + "expiresAt": expires_at, | |
| 1529 | + }); | |
| 1530 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1531 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1532 | + | |
| 1533 | + // 1st POST — valid. | |
| 1534 | + let (date1, host1, sig1) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1535 | + let r1 = client | |
| 1536 | + .post(target_url) | |
| 1537 | + .header("Host", host1) | |
| 1538 | + .header("Date", date1) | |
| 1539 | + .header("Signature", sig1) | |
| 1540 | + .header("Content-Type", "application/activity+json") | |
| 1541 | + .body(body_bytes.clone()) | |
| 1542 | + .send() | |
| 1543 | + .await | |
| 1544 | + .expect("1st POST must complete"); | |
| 1545 | + assert_eq!(r1.status(), StatusCode::ACCEPTED, "first POST must 202"); | |
| 1546 | + | |
| 1547 | + // 2nd POST — same nonce, fresh date/signature. | |
| 1548 | + let (date2, host2, sig2) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1549 | + let r2 = client | |
| 1550 | + .post(target_url) | |
| 1551 | + .header("Host", host2) | |
| 1552 | + .header("Date", date2) | |
| 1553 | + .header("Signature", sig2) | |
| 1554 | + .header("Content-Type", "application/activity+json") | |
| 1555 | + .body(body_bytes) | |
| 1556 | + .send() | |
| 1557 | + .await | |
| 1558 | + .expect("2nd POST must complete"); | |
| 1559 | + assert!( | |
| 1560 | + !r2.status().is_success(), | |
| 1561 | + "replayed AuthRequest must NOT succeed; got {}", | |
| 1562 | + r2.status() | |
| 1563 | + ); | |
| 1564 | +} | |
| 1565 | + | |
| 1566 | +#[tokio::test] | |
| 1567 | +async fn s5_webfinger_returns_404_for_unknown_user() { | |
| 1568 | + let Some(client) = testbed_or_skip().await else { | |
| 1569 | + return; | |
| 1570 | + }; | |
| 1571 | + let response = client | |
| 1572 | + .get(format!("{ALPHA}/.well-known/webfinger")) | |
| 1573 | + .query(&[("resource", "acct:ghost-doesnt-exist@alpha.local")]) | |
| 1574 | + .send() | |
| 1575 | + .await | |
| 1576 | + .expect("webfinger request must complete"); | |
| 1577 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 1578 | +} | |
| 1579 | + | |
| 1580 | +#[tokio::test] | |
| 1581 | +async fn s5_initiate_rejects_malformed_handle() { | |
| 1582 | + let Some(client) = testbed_or_skip().await else { | |
| 1583 | + return; | |
| 1584 | + }; | |
| 1585 | + // Handle missing @ → service Validation → 4xx (typically 400). | |
| 1586 | + let response = client | |
| 1587 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1588 | + .json(&json!({ "handle": "not-a-handle-no-at-sign" })) | |
| 1589 | + .send() | |
| 1590 | + .await | |
| 1591 | + .expect("initiate must complete"); | |
| 1592 | + assert!( | |
| 1593 | + response.status().is_client_error(), | |
| 1594 | + "malformed handle must yield 4xx, got {}", | |
| 1595 | + response.status() | |
| 1596 | + ); | |
| 1597 | +} | |
| 1598 | + | |
| 1599 | +#[tokio::test] | |
| 1600 | +async fn s5_finding_f11_clock_skew_not_enforced_with_debug_verifier() { | |
| 1601 | + // Plan §S5 expects: signed request with Date header from 1+ hour ago | |
| 1602 | + // must be rejected. With AlwaysAcceptVerifier (testbed-default), no | |
| 1603 | + // Date enforcement is done — only HttpSigVerifier (production) | |
| 1604 | + // checks the window. This test documents the gap (F11) ; when the | |
| 1605 | + // testbed is reconfigured to use HttpSigVerifier (future RFC), flip | |
| 1606 | + // this assertion to expect 401. | |
| 1607 | + | |
| 1608 | + let Some(_) = testbed_or_skip().await else { | |
| 1609 | + return; | |
| 1610 | + }; | |
| 1611 | + let client = signing_client(); | |
| 1612 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1613 | + | |
| 1614 | + let nonce = Uuid::new_v4().to_string(); | |
| 1615 | + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339(); | |
| 1616 | + let payload = json!({ | |
| 1617 | + "type": "AuthRequest", | |
| 1618 | + "actor": alpha.actor_url, | |
| 1619 | + "target": "joanie@beta.local", | |
| 1620 | + "nonce": nonce, | |
| 1621 | + "expiresAt": expires_at, | |
| 1622 | + }); | |
| 1623 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1624 | + let target_url = "http://beta.local/fediverse/auth/inbox"; | |
| 1625 | + | |
| 1626 | + // Sign with a Date header that's 2 hours stale. The signature itself | |
| 1627 | + // is correctly computed over the stale Date, so signature validation | |
| 1628 | + // would still verify if performed — but the freshness check is what | |
| 1629 | + // should reject. | |
| 1630 | + let stale_date = (chrono::Utc::now() - chrono::Duration::hours(2)) | |
| 1631 | + .format("%a, %d %b %Y %H:%M:%S GMT") | |
| 1632 | + .to_string(); | |
| 1633 | + use base64::Engine; | |
| 1634 | + use rustwarden_fediverse::http_signatures::{build_signature_input, rsa_sha256_sign}; | |
| 1635 | + let signed = vec![ | |
| 1636 | + "(request-target)".to_string(), | |
| 1637 | + "host".to_string(), | |
| 1638 | + "date".to_string(), | |
| 1639 | + ]; | |
| 1640 | + let request_target = "post /fediverse/auth/inbox".to_string(); | |
| 1641 | + let host = "beta.local".to_string(); | |
| 1642 | + let date_for_input = stale_date.clone(); | |
| 1643 | + let input = build_signature_input(&signed, &request_target, |h| match h { | |
| 1644 | + "host" => Some(host.clone()), | |
| 1645 | + "date" => Some(date_for_input.clone()), | |
| 1646 | + _ => None, | |
| 1647 | + }) | |
| 1648 | + .unwrap(); | |
| 1649 | + let sig_bytes = rsa_sha256_sign(&alpha.private_pem, input.as_bytes()).unwrap(); | |
| 1650 | + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes); | |
| 1651 | + let sig_header = format!( | |
| 1652 | + r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#, | |
| 1653 | + alpha.actor_url | |
| 1654 | + ); | |
| 1655 | + | |
| 1656 | + let resp = client | |
| 1657 | + .post(target_url) | |
| 1658 | + .header("Host", host) | |
| 1659 | + .header("Date", stale_date) | |
| 1660 | + .header("Signature", sig_header) | |
| 1661 | + .header("Content-Type", "application/activity+json") | |
| 1662 | + .body(body_bytes) | |
| 1663 | + .send() | |
| 1664 | + .await | |
| 1665 | + .expect("POST must complete"); | |
| 1666 | + | |
| 1667 | + // F11 STATUS UPDATE: with HttpSigVerifier replacing AlwaysAcceptVerifier | |
| 1668 | + // in the testbed, this should become 401. Current: 202 accepts the | |
| 1669 | + // stale Date (only signature is checked, not freshness). | |
| 1670 | + assert_eq!( | |
| 1671 | + resp.status(), | |
| 1672 | + StatusCode::ACCEPTED, | |
| 1673 | + "F11 STATUS UPDATE: if this is now 401, real verifier is in use" | |
| 1674 | + ); | |
| 1675 | +} | |
| 1676 | + | |
| 1677 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1678 | +// Iteration 6 (S6) — Lifecycle Update / Delete Person activities | |
| 1679 | +// | |
| 1680 | +// AP convention: when alpha's user updates her profile, alpha pushes | |
| 1681 | +// `Update(Person)` to beta's `/inbox`. Beta's `dispatch_update` | |
| 1682 | +// refreshes the cached `remote_identities` row. Similarly, | |
| 1683 | +// `Delete(Person)` disables the row — combined with F10, any active | |
| 1684 | +// remote JWT for that user is immediately invalid. | |
| 1685 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1686 | + | |
| 1687 | +#[tokio::test] | |
| 1688 | +async fn s6_update_person_refreshes_cached_display_name() { | |
| 1689 | + let Some(_) = testbed_or_skip().await else { | |
| 1690 | + return; | |
| 1691 | + }; | |
| 1692 | + let client = signing_client(); | |
| 1693 | + | |
| 1694 | + // Make sure beta has joanie@alpha cached (F2 auto-fetch via verifier). | |
| 1695 | + ensure_joanie_alpha_in_beta_cache(&client).await; | |
| 1696 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1697 | + | |
| 1698 | + let new_name = format!("Joanie-Updated-{}", &Uuid::new_v4().to_string()[..8]); | |
| 1699 | + | |
| 1700 | + let payload = json!({ | |
| 1701 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1702 | + "type": "Update", | |
| 1703 | + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1704 | + "actor": alpha.actor_url, | |
| 1705 | + "object": { | |
| 1706 | + "id": alpha.actor_url, | |
| 1707 | + "type": "Person", | |
| 1708 | + "name": new_name, | |
| 1709 | + "preferredUsername": "joanie", | |
| 1710 | + }, | |
| 1711 | + }); | |
| 1712 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1713 | + let target_url = "http://beta.local/inbox"; | |
| 1714 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1715 | + | |
| 1716 | + let resp = client | |
| 1717 | + .post(target_url) | |
| 1718 | + .header("Host", host) | |
| 1719 | + .header("Date", date) | |
| 1720 | + .header("Signature", sig) | |
| 1721 | + .header("Content-Type", "application/activity+json") | |
| 1722 | + .body(body_bytes) | |
| 1723 | + .send() | |
| 1724 | + .await | |
| 1725 | + .expect("Update POST must complete"); | |
| 1726 | + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Update(Person) must 202"); | |
| 1727 | + | |
| 1728 | + let display = psql( | |
| 1729 | + "beta", | |
| 1730 | + &format!( | |
| 1731 | + "SELECT display_name FROM remote_identities WHERE ap_id='{}'", | |
| 1732 | + alpha.actor_url | |
| 1733 | + ), | |
| 1734 | + ); | |
| 1735 | + assert_eq!(display, new_name, "display_name must be refreshed on beta"); | |
| 1736 | +} | |
| 1737 | + | |
| 1738 | +#[tokio::test] | |
| 1739 | +async fn s6_delete_person_disables_remote_identity_and_kills_active_jwt() { | |
| 1740 | + let Some(_) = testbed_or_skip().await else { | |
| 1741 | + return; | |
| 1742 | + }; | |
| 1743 | + let client = signing_client(); | |
| 1744 | + | |
| 1745 | + // 1. Full S1 round-trip → minted remote JWT. | |
| 1746 | + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await; | |
| 1747 | + let alpha = joanie_alpha_keys(&client).await; | |
| 1748 | + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await; | |
| 1749 | + let consume = client | |
| 1750 | + .post(format!( | |
| 1751 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1752 | + )) | |
| 1753 | + .send() | |
| 1754 | + .await | |
| 1755 | + .unwrap(); | |
| 1756 | + assert_eq!(consume.status(), StatusCode::OK); | |
| 1757 | + let jwt = consume.json::<Value>().await.unwrap()["jwt"] | |
| 1758 | + .as_str() | |
| 1759 | + .unwrap() | |
| 1760 | + .to_string(); | |
| 1761 | + | |
| 1762 | + // 2. Alpha pushes Delete(Person) to beta's inbox. | |
| 1763 | + let payload = json!({ | |
| 1764 | + "@context": ["https://www.w3.org/ns/activitystreams"], | |
| 1765 | + "type": "Delete", | |
| 1766 | + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()), | |
| 1767 | + "actor": alpha.actor_url, | |
| 1768 | + "object": { "id": alpha.actor_url, "type": "Person" }, | |
| 1769 | + }); | |
| 1770 | + let body_bytes = serde_json::to_vec(&payload).unwrap(); | |
| 1771 | + let target_url = "http://beta.local/inbox"; | |
| 1772 | + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url); | |
| 1773 | + let resp = client | |
| 1774 | + .post(target_url) | |
| 1775 | + .header("Host", host) | |
| 1776 | + .header("Date", date) | |
| 1777 | + .header("Signature", sig) | |
| 1778 | + .header("Content-Type", "application/activity+json") | |
| 1779 | + .body(body_bytes) | |
| 1780 | + .send() | |
| 1781 | + .await | |
| 1782 | + .expect("Delete POST must complete"); | |
| 1783 | + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Delete(Person) must 202"); | |
| 1784 | + | |
| 1785 | + // 3. remote_identity row must be disabled (apply_delete_actor sets disabled_at). | |
| 1786 | + let disabled = psql( | |
| 1787 | + "beta", | |
| 1788 | + &format!( | |
| 1789 | + "SELECT disabled_at IS NOT NULL FROM remote_identities WHERE ap_id='{}'", | |
| 1790 | + alpha.actor_url | |
| 1791 | + ), | |
| 1792 | + ); | |
| 1793 | + assert_eq!(disabled, "t", "remote_identity must be disabled"); | |
| 1794 | + | |
| 1795 | + // 4. With F10 in effect, the previously-minted JWT must now be | |
| 1796 | + // rejected at the next protected request. | |
| 1797 | + let no_redirect_client = reqwest::Client::builder() | |
| 1798 | + .redirect(reqwest::redirect::Policy::none()) | |
| 1799 | + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap()) | |
| 1800 | + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap()) | |
| 1801 | + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap()) | |
| 1802 | + .timeout(std::time::Duration::from_secs(5)) | |
| 1803 | + .build() | |
| 1804 | + .unwrap(); | |
| 1805 | + let resp = no_redirect_client | |
| 1806 | + .get("http://beta.local/profile/connected_apps") | |
| 1807 | + .header("Cookie", format!("jwt_token={jwt}")) | |
| 1808 | + .send() | |
| 1809 | + .await | |
| 1810 | + .unwrap(); | |
| 1811 | + let location = resp | |
| 1812 | + .headers() | |
| 1813 | + .get("location") | |
| 1814 | + .and_then(|v| v.to_str().ok()) | |
| 1815 | + .map(str::to_string); | |
| 1816 | + | |
| 1817 | + // Restore so subsequent tests see a clean slate. | |
| 1818 | + psql( | |
| 1819 | + "beta", | |
| 1820 | + &format!( | |
| 1821 | + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \ | |
| 1822 | + WHERE ap_id='{}'", | |
| 1823 | + alpha.actor_url | |
| 1824 | + ), | |
| 1825 | + ); | |
| 1826 | + | |
| 1827 | + let redirected_login = location.as_deref().is_some_and(|s| s.contains("/login")); | |
| 1828 | + assert!( | |
| 1829 | + resp.status().is_redirection() && redirected_login, | |
| 1830 | + "JWT must be invalidated after Delete(Person); got status {} location {:?}", | |
| 1831 | + resp.status(), | |
| 1832 | + location | |
| 1833 | + ); | |
| 1834 | +} | |
| 1835 | + | |
| 1836 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1837 | +// Iteration 1c: S1 — consume mints a remote JWT when a B-side challenge | |
| 1838 | +// reaches the `approved` state. | |
| 1839 | +// | |
| 1840 | +// MAJOR PROTOCOL FINDING from this campaign (testbed iteration 1c): | |
| 1841 | +// the codebase has NO mechanism for propagating an approval from the | |
| 1842 | +// IdP (A) back to the SP (B). A's `consent` API flips the challenge on | |
| 1843 | +// A's side; B's `consume` reads its OWN local challenge; nothing connects | |
| 1844 | +// the two. There is no `AuthApproval` activity in the inbox handler. | |
| 1845 | +// | |
| 1846 | +// Conclusion: the full S1 SSO bilateral flow CANNOT round-trip with the | |
| 1847 | +// current code. This test therefore validates only the B-side half: | |
| 1848 | +// given a challenge already marked `approved` (which we simulate via | |
| 1849 | +// DB-direct UPDATE to stand in for the missing back-channel), the | |
| 1850 | +// `consume` endpoint must mint a valid JWT carrying the remote identity. | |
| 1851 | +// | |
| 1852 | +// Closing this gap requires an RFC and is OUT OF SCOPE for the campaign | |
| 1853 | +// quality-gate; it is reported as a blocker for promotion to stable. | |
| 1854 | +// ─────────────────────────────────────────────────────────────────────── | |
| 1855 | + | |
| 1856 | +#[tokio::test] | |
| 1857 | +async fn s1_consume_on_beta_mints_jwt_when_challenge_approved() { | |
| 1858 | + let Some(_) = testbed_or_skip().await else { | |
| 1859 | + return; | |
| 1860 | + }; | |
| 1861 | + let client = signing_client(); | |
| 1862 | + | |
| 1863 | + // 1. Initiate on B against the handle `joanie@alpha.local` — creates a | |
| 1864 | + // real Pending challenge on B so the test exercises the normal entry | |
| 1865 | + // point, not a synthetic row. | |
| 1866 | + let initiate: Value = client | |
| 1867 | + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate")) | |
| 1868 | + .json(&json!({ "handle": "joanie@alpha.local" })) | |
| 1869 | + .send() | |
| 1870 | + .await | |
| 1871 | + .expect("initiate must succeed") | |
| 1872 | + .json() | |
| 1873 | + .await | |
| 1874 | + .expect("initiate body must be JSON"); | |
| 1875 | + let challenge_id = initiate["challenge_id"] | |
| 1876 | + .as_str() | |
| 1877 | + .expect("challenge_id missing") | |
| 1878 | + .to_string(); | |
| 1879 | + | |
| 1880 | + // 2. Seed B with a remote_identity for joanie@alpha. We need joanie@alpha's | |
| 1881 | + // public PEM (which alpha has generated for any actor fetch we do here). | |
| 1882 | + let alpha_actor: Value = client | |
| 1883 | + .get(format!("{ALPHA}/users/joanie")) | |
| 1884 | + .header("Accept", "application/activity+json") | |
| 1885 | + .send() | |
| 1886 | + .await | |
| 1887 | + .expect("alpha actor fetch must succeed") | |
| 1888 | + .json() | |
| 1889 | + .await | |
| 1890 | + .expect("alpha actor JSON must parse"); | |
| 1891 | + let alpha_public_pem = alpha_actor["publicKey"]["publicKeyPem"] | |
| 1892 | + .as_str() | |
| 1893 | + .unwrap() | |
| 1894 | + .to_string(); | |
| 1895 | + let alpha_inbox = alpha_actor["inbox"].as_str().unwrap().to_string(); | |
| 1896 | + let alpha_actor_id = alpha_actor["id"].as_str().unwrap().to_string(); | |
| 1897 | + | |
| 1898 | + upsert_remote_identity( | |
| 1899 | + "beta", | |
| 1900 | + &alpha_actor_id, | |
| 1901 | + "joanie", | |
| 1902 | + "alpha.local", | |
| 1903 | + &alpha_inbox, | |
| 1904 | + &alpha_public_pem, | |
| 1905 | + ); | |
| 1906 | + | |
| 1907 | + // 3. DB-direct: simulate the missing back-channel by flipping B's | |
| 1908 | + // challenge to `approved` and binding it to the remote_identity we just | |
| 1909 | + // seeded. | |
| 1910 | + psql( | |
| 1911 | + "beta", | |
| 1912 | + &format!( | |
| 1913 | + "UPDATE fediverse_auth_challenges | |
| 1914 | + SET status = 'approved', | |
| 1915 | + requested_remote_id = (SELECT id FROM remote_identities | |
| 1916 | + WHERE ap_id = '{alpha_actor_id}') | |
| 1917 | + WHERE id = '{challenge_id}'" | |
| 1918 | + ), | |
| 1919 | + ); | |
| 1920 | + | |
| 1921 | + // 4. Consume — B must mint a remote JWT from the approved challenge. | |
| 1922 | + let consume = client | |
| 1923 | + .post(format!( | |
| 1924 | + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}" | |
| 1925 | + )) | |
| 1926 | + .send() | |
| 1927 | + .await | |
| 1928 | + .expect("consume request must complete"); | |
| 1929 | + let status = consume.status(); | |
| 1930 | + let body_text = consume.text().await.unwrap_or_default(); | |
| 1931 | + assert_eq!( | |
| 1932 | + status, | |
| 1933 | + StatusCode::OK, | |
| 1934 | + "consume must return 200 for an approved challenge; got {status} body={body_text}" | |
| 1935 | + ); | |
| 1936 | + | |
| 1937 | + let body: Value = serde_json::from_str(&body_text).expect("consume body must be JSON"); | |
| 1938 | + let jwt = body["jwt"].as_str().expect("consume response must carry jwt"); | |
| 1939 | + let parts: Vec<&str> = jwt.split('.').collect(); | |
| 1940 | + assert_eq!(parts.len(), 3, "JWT must have header.payload.signature"); | |
| 1941 | + | |
| 1942 | + // Decode the payload (middle segment) without verifying the signature — | |
| 1943 | + // we just check the claims shape, not crypto. | |
| 1944 | + let payload_b64 = parts[1]; | |
| 1945 | + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD | |
| 1946 | + .decode(payload_b64) | |
| 1947 | + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_b64)) | |
| 1948 | + .expect("JWT payload must be base64-url"); | |
| 1949 | + let claims: Value = serde_json::from_slice(&payload_bytes).expect("JWT payload must be JSON"); | |
| 1950 | + | |
| 1951 | + assert_eq!( | |
| 1952 | + claims["kind"], "remote", | |
| 1953 | + "remote JWT must carry kind=remote, got claims: {claims}" | |
| 1954 | + ); | |
| 1955 | + assert_eq!(claims["username"], "joanie@alpha.local"); | |
| 1956 | + assert!( | |
| 1957 | + claims["remote_id"].is_string(), | |
| 1958 | + "remote JWT must carry remote_id, got claims: {claims}" | |
| 1959 | + ); | |
| 1960 | + | |
| 1961 | + // 5. The challenge must now be marked consumed (single-use guarantee). | |
| 1962 | + let consumed_count = psql( | |
| 1963 | + "beta", | |
| 1964 | + &format!( | |
| 1965 | + "SELECT COUNT(*) FROM fediverse_auth_challenges | |
| 1966 | + WHERE id = '{challenge_id}' AND status = 'consumed'" | |
| 1967 | + ), | |
| 1968 | + ); | |
| 1969 | + assert_eq!(consumed_count, "1", "challenge must be marked consumed"); | |
| 1970 | +} | |
| 1971 | + | |
| 1972 | +#[tokio::test] | |
| 1973 | +async fn auth_inbox_rejects_invalid_signature() { | |
| 1974 | + let Some(client) = testbed_or_skip().await else { | |
| 1975 | + return; | |
| 1976 | + }; | |
| 1977 | + // Bogus Signature header that won't parse / verify. | |
| 1978 | + let response = client | |
| 1979 | + .post(format!("{ALPHA}/fediverse/auth/inbox")) | |
| 1980 | + .header("Content-Type", "application/activity+json") | |
| 1981 | + .header("Date", "Sun, 10 May 2026 21:00:00 GMT") | |
| 1982 | + .header( | |
| 1983 | + "Signature", | |
| 1984 | + r#"keyId="http://beta.local/users/joanie#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AAAA""#, | |
| 1985 | + ) | |
| 1986 | + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#) | |
| 1987 | + .send() | |
| 1988 | + .await | |
| 1989 | + .expect("request must succeed"); | |
| 1990 | + assert!( | |
| 1991 | + response.status().is_client_error(), | |
| 1992 | + "invalid signature must yield 4xx, got {}", | |
| 1993 | + response.status() | |
| 1994 | + ); | |
| 1995 | +} |
A
crates/rustwarden-fediverse/tests/fediverse_interop.rs
+314
-0
@@ -0,0 +1,314 @@
| 1 | +//! RFC 0020 — Fediverse interoperability tests. | |
| 2 | +//! | |
| 3 | +//! Spawns a `wiremock` HTTP server playing the role of a remote | |
| 4 | +//! ActivityPub inbox (Mastodon-style) and exercises the signing / | |
| 5 | +//! verification chain end-to-end: | |
| 6 | +//! | |
| 7 | +//! 1. `signed_request_round_trip_through_wiremock` — build + sign + | |
| 8 | +//! POST a request, capture it on the receiver side, re-verify the | |
| 9 | +//! signature against the same public PEM via `parse_and_verify_cavage`. | |
| 10 | +//! 2. `outbound_delivers_when_inbox_returns_202` — `drain_one` with a | |
| 11 | +//! delivery impl that returns `Ok` on 2xx flips the row to | |
| 12 | +//! `Delivered`. | |
| 13 | +//! 3. `outbound_defers_when_inbox_returns_503` — the same harness with | |
| 14 | +//! a 503 leaves the row in `Pending` with `next_retry_at` set per | |
| 15 | +//! RFC 0010. | |
| 16 | + | |
| 17 | + | |
| 18 | +use std::sync::Arc; | |
| 19 | + | |
| 20 | +use async_trait::async_trait; | |
| 21 | +use base64::Engine; | |
| 22 | +use chrono::Utc; | |
| 23 | +use reqwest::Client; | |
| 24 | +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, MockExecResult}; | |
| 25 | +use uuid::Uuid; | |
| 26 | +use wiremock::matchers::{header_exists, method, path}; | |
| 27 | +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; | |
| 28 | + | |
| 29 | +use rustwarden_core::error::AppError; | |
| 30 | +use rustwarden_fediverse::http_signatures::{ | |
| 31 | + build_signature_input, parse_and_verify_cavage, rsa_sha256_sign, | |
| 32 | +}; | |
| 33 | +use rustwarden_fediverse::models::fediverse_outbound_queue; | |
| 34 | +use rustwarden_fediverse::services::actor_key_service::ActorKeyService; | |
| 35 | +use rustwarden_fediverse::services::fediverse_outbound_service::{ | |
| 36 | + FediverseOutboundService, OutboundDelivery, OutboundStatus, | |
| 37 | +}; | |
| 38 | + | |
| 39 | +/// Build the canonical `Signature` header for a draft-cavage signed | |
| 40 | +/// POST. Mirrors `HttpSignedDelivery::deliver` but lives in the test | |
| 41 | +/// module so the round-trip can be exercised without DB plumbing. | |
| 42 | +fn sign_post( | |
| 43 | + private_pem: &str, | |
| 44 | + key_id: &str, | |
| 45 | + host: &str, | |
| 46 | + path: &str, | |
| 47 | + date: &str, | |
| 48 | +) -> String { | |
| 49 | + let signed_headers = vec![ | |
| 50 | + "(request-target)".to_string(), | |
| 51 | + "host".to_string(), | |
| 52 | + "date".to_string(), | |
| 53 | + ]; | |
| 54 | + let request_target = format!("post {path}"); | |
| 55 | + let input = build_signature_input(&signed_headers, &request_target, |h| match h { | |
| 56 | + "host" => Some(host.to_string()), | |
| 57 | + "date" => Some(date.to_string()), | |
| 58 | + _ => None, | |
| 59 | + }) | |
| 60 | + .expect("build_signature_input"); | |
| 61 | + | |
| 62 | + let signature_bytes = | |
| 63 | + rsa_sha256_sign(private_pem, input.as_bytes()).expect("sign"); | |
| 64 | + let signature_b64 = | |
| 65 | + base64::engine::general_purpose::STANDARD.encode(&signature_bytes); | |
| 66 | + | |
| 67 | + format!( | |
| 68 | + r#"keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date",signature="{signature_b64}""#, | |
| 69 | + ) | |
| 70 | +} | |
| 71 | + | |
| 72 | +#[tokio::test] | |
| 73 | +async fn signed_request_round_trip_through_wiremock() { | |
| 74 | + let server = MockServer::start().await; | |
| 75 | + let inbox_path = "/inbox"; | |
| 76 | + | |
| 77 | + Mock::given(method("POST")) | |
| 78 | + .and(path(inbox_path)) | |
| 79 | + .and(header_exists("signature")) | |
| 80 | + .respond_with(ResponseTemplate::new(202)) | |
| 81 | + .expect(1) | |
| 82 | + .mount(&server) | |
| 83 | + .await; | |
| 84 | + | |
| 85 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 86 | + let url = format!("{}{inbox_path}", server.uri()); | |
| 87 | + let parsed = reqwest::Url::parse(&url).unwrap(); | |
| 88 | + let host = parsed.host_str().unwrap().to_string(); | |
| 89 | + let host_with_port = match parsed.port() { | |
| 90 | + Some(p) => format!("{host}:{p}"), | |
| 91 | + None => host.clone(), | |
| 92 | + }; | |
| 93 | + let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 94 | + let key_id = "https://a.tld/users/alice#main-key"; | |
| 95 | + | |
| 96 | + let signature_header = sign_post( | |
| 97 | + &material.private_pem, | |
| 98 | + key_id, | |
| 99 | + &host_with_port, | |
| 100 | + inbox_path, | |
| 101 | + &date, | |
| 102 | + ); | |
| 103 | + | |
| 104 | + let body = br#"{"@context":"https://www.w3.org/ns/activitystreams","type":"Update"}"#; | |
| 105 | + | |
| 106 | + let response = Client::new() | |
| 107 | + .post(&url) | |
| 108 | + .header("Date", &date) | |
| 109 | + .header("Host", &host_with_port) | |
| 110 | + .header("Signature", &signature_header) | |
| 111 | + .header("Content-Type", "application/activity+json") | |
| 112 | + .body(body.to_vec()) | |
| 113 | + .send() | |
| 114 | + .await | |
| 115 | + .expect("post ok"); | |
| 116 | + assert_eq!(response.status().as_u16(), 202); | |
| 117 | + | |
| 118 | + // Capture the request that wiremock received and re-verify the | |
| 119 | + // signature against the published public PEM. This proves the | |
| 120 | + // signed input string is what reqwest actually puts on the wire. | |
| 121 | + let received: Vec<Request> = server.received_requests().await.unwrap(); | |
| 122 | + assert_eq!(received.len(), 1); | |
| 123 | + let req = &received[0]; | |
| 124 | + | |
| 125 | + let header_lookup = |name: &str| -> Option<String> { | |
| 126 | + // wiremock lowercases header names already. | |
| 127 | + req.headers | |
| 128 | + .get(name) | |
| 129 | + .map(|v| v.to_str().unwrap().to_string()) | |
| 130 | + }; | |
| 131 | + | |
| 132 | + let sig = header_lookup("signature").expect("signature header captured"); | |
| 133 | + parse_and_verify_cavage( | |
| 134 | + &sig, | |
| 135 | + &format!("post {inbox_path}"), | |
| 136 | + &req.body, | |
| 137 | + &material.public_pem, | |
| 138 | + header_lookup, | |
| 139 | + ) | |
| 140 | + .expect("signature must round-trip through wiremock"); | |
| 141 | +} | |
| 142 | + | |
| 143 | +/// Minimal `OutboundDelivery` that signs + posts via reqwest using a | |
| 144 | +/// pre-supplied keypair. Mirrors `HttpSignedDelivery::deliver` without | |
| 145 | +/// the DB lookup so we can exercise `drain_one` against wiremock with | |
| 146 | +/// only a `MockDatabase` for the queue. | |
| 147 | +struct InMemorySignedDelivery { | |
| 148 | + private_pem: String, | |
| 149 | + key_id: String, | |
| 150 | +} | |
| 151 | + | |
| 152 | +#[async_trait] | |
| 153 | +impl OutboundDelivery for InMemorySignedDelivery { | |
| 154 | + async fn deliver( | |
| 155 | + &self, | |
| 156 | + item: &fediverse_outbound_queue::Model, | |
| 157 | + ) -> Result<(), AppError> { | |
| 158 | + let url = reqwest::Url::parse(&item.target_inbox) | |
| 159 | + .map_err(|e| AppError::Internal(format!("bad URL: {e}")))?; | |
| 160 | + let host = url | |
| 161 | + .host_str() | |
| 162 | + .ok_or_else(|| AppError::Internal("no host".into()))? | |
| 163 | + .to_string(); | |
| 164 | + let host_with_port = match url.port() { | |
| 165 | + Some(p) => format!("{host}:{p}"), | |
| 166 | + None => host.clone(), | |
| 167 | + }; | |
| 168 | + let path = url.path().to_string(); | |
| 169 | + let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string(); | |
| 170 | + | |
| 171 | + let signature_header = sign_post( | |
| 172 | + &self.private_pem, | |
| 173 | + &self.key_id, | |
| 174 | + &host_with_port, | |
| 175 | + &path, | |
| 176 | + &date, | |
| 177 | + ); | |
| 178 | + | |
| 179 | + let response = Client::new() | |
| 180 | + .post(&item.target_inbox) | |
| 181 | + .header("Date", &date) | |
| 182 | + .header("Host", &host_with_port) | |
| 183 | + .header("Signature", &signature_header) | |
| 184 | + .header("Content-Type", "application/activity+json") | |
| 185 | + .body(item.payload_json.clone()) | |
| 186 | + .send() | |
| 187 | + .await | |
| 188 | + .map_err(|e| AppError::Internal(format!("HTTP delivery failed: {e}")))?; | |
| 189 | + | |
| 190 | + if response.status().is_success() { | |
| 191 | + Ok(()) | |
| 192 | + } else { | |
| 193 | + Err(AppError::Internal(format!( | |
| 194 | + "remote inbox responded {}", | |
| 195 | + response.status(), | |
| 196 | + ))) | |
| 197 | + } | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +fn pending_row(target_inbox: &str, target_domain: &str) -> fediverse_outbound_queue::Model { | |
| 202 | + fediverse_outbound_queue::Model { | |
| 203 | + id: Uuid::new_v4(), | |
| 204 | + activity_id: "https://a.tld/activities/1".to_string(), | |
| 205 | + payload_json: r#"{"type":"Update"}"#.to_string(), | |
| 206 | + target_inbox: target_inbox.to_string(), | |
| 207 | + target_domain: target_domain.to_string(), | |
| 208 | + status: "pending".to_string(), | |
| 209 | + attempts: 0, | |
| 210 | + max_attempts: 8, | |
| 211 | + last_error: None, | |
| 212 | + next_retry_at: None, | |
| 213 | + created_at: Utc::now(), | |
| 214 | + delivered_at: None, | |
| 215 | + actor_user_id: Some(Uuid::new_v4()), | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +#[tokio::test] | |
| 220 | +async fn outbound_delivers_when_inbox_returns_202() { | |
| 221 | + let server = MockServer::start().await; | |
| 222 | + Mock::given(method("POST")) | |
| 223 | + .and(path("/inbox")) | |
| 224 | + .respond_with(ResponseTemplate::new(202)) | |
| 225 | + .expect(1) | |
| 226 | + .mount(&server) | |
| 227 | + .await; | |
| 228 | + | |
| 229 | + let target_inbox = format!("{}/inbox", server.uri()); | |
| 230 | + let row = pending_row(&target_inbox, "wiremock.local"); | |
| 231 | + | |
| 232 | + // Two fixtures: the SELECT-candidate then the SELECT-after-update | |
| 233 | + // (sea-orm mock returns subsequent fixtures for `.update()` reads). | |
| 234 | + let mut delivered = row.clone(); | |
| 235 | + delivered.status = OutboundStatus::Delivered.as_str().to_string(); | |
| 236 | + delivered.attempts = 1; | |
| 237 | + delivered.delivered_at = Some(Utc::now()); | |
| 238 | + | |
| 239 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 240 | + .append_query_results([vec![row.clone()]]) | |
| 241 | + .append_query_results([vec![delivered.clone()]]) | |
| 242 | + .append_exec_results([MockExecResult { | |
| 243 | + last_insert_id: 0, | |
| 244 | + rows_affected: 1, | |
| 245 | + }]) | |
| 246 | + .into_connection(); | |
| 247 | + | |
| 248 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 249 | + let delivery = InMemorySignedDelivery { | |
| 250 | + private_pem: material.private_pem, | |
| 251 | + key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 252 | + }; | |
| 253 | + | |
| 254 | + let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 255 | + .await | |
| 256 | + .expect("drain ok") | |
| 257 | + .expect("row drained"); | |
| 258 | + | |
| 259 | + assert_eq!(result.status, OutboundStatus::Delivered.as_str()); | |
| 260 | + assert_eq!(result.attempts, 1); | |
| 261 | + assert!(result.delivered_at.is_some()); | |
| 262 | +} | |
| 263 | + | |
| 264 | +#[tokio::test] | |
| 265 | +async fn outbound_defers_when_inbox_returns_503() { | |
| 266 | + let server = MockServer::start().await; | |
| 267 | + Mock::given(method("POST")) | |
| 268 | + .and(path("/inbox")) | |
| 269 | + .respond_with(ResponseTemplate::new(503)) | |
| 270 | + .expect(1) | |
| 271 | + .mount(&server) | |
| 272 | + .await; | |
| 273 | + | |
| 274 | + let target_inbox = format!("{}/inbox", server.uri()); | |
| 275 | + let row = pending_row(&target_inbox, "wiremock.local"); | |
| 276 | + | |
| 277 | + let mut deferred = row.clone(); | |
| 278 | + deferred.status = OutboundStatus::Pending.as_str().to_string(); | |
| 279 | + deferred.attempts = 1; | |
| 280 | + deferred.last_error = Some("remote inbox responded 503".to_string()); | |
| 281 | + deferred.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5)); | |
| 282 | + | |
| 283 | + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres) | |
| 284 | + .append_query_results([vec![row.clone()]]) | |
| 285 | + .append_query_results([vec![deferred.clone()]]) | |
| 286 | + .append_exec_results([MockExecResult { | |
| 287 | + last_insert_id: 0, | |
| 288 | + rows_affected: 1, | |
| 289 | + }]) | |
| 290 | + .into_connection(); | |
| 291 | + | |
| 292 | + let material = ActorKeyService::generate().expect("RSA gen"); | |
| 293 | + let delivery = InMemorySignedDelivery { | |
| 294 | + private_pem: material.private_pem, | |
| 295 | + key_id: "https://a.tld/users/alice#main-key".to_string(), | |
| 296 | + }; | |
| 297 | + | |
| 298 | + let result = FediverseOutboundService::drain_one(&db, &delivery) | |
| 299 | + .await | |
| 300 | + .expect("drain ok") | |
| 301 | + .expect("row drained"); | |
| 302 | + | |
| 303 | + assert_eq!(result.status, OutboundStatus::Pending.as_str()); | |
| 304 | + assert_eq!(result.attempts, 1); | |
| 305 | + assert!(result.last_error.is_some()); | |
| 306 | + assert!(result.next_retry_at.is_some()); | |
| 307 | +} | |
| 308 | + | |
| 309 | +// `Arc` is unused in this file but documents the intent of sharing the | |
| 310 | +// MockServer handle across spawned tasks should we extend the harness. | |
| 311 | +#[allow(dead_code)] | |
| 312 | +fn _share_server(server: MockServer) -> Arc<MockServer> { | |
| 313 | + Arc::new(server) | |
| 314 | +} |
M
crates/rustwarden-ui/Cargo.toml
+8
-5
@@ -36,15 +36,18 @@ num-format = "0.4.4"
| 36 | 36 | # Time |
| 37 | 37 | time = { version = "0.3", features = ["macros", "serde"] } |
| 38 | 38 | # Stream construction for SSE handlers (RFC 0023 Slice B iter. 3). |
| 39 | -# Optional: only pulled when `experimental-fediverse` is on. | |
| 39 | +# Optional: only pulled when `fediverse` is on. | |
| 40 | 40 | futures = { version = "0.3", optional = true } |
| 41 | +# ActivityPub identity federation (RFC 0001+) extracted to its own crate. | |
| 42 | +rustwarden-fediverse = { path = "../rustwarden-fediverse", optional = true } | |
| 41 | 43 | |
| 42 | 44 | [features] |
| 43 | 45 | default = [] |
| 44 | -# RFC 0001: pass-through to enable federation primitives in core. UI templates | |
| 45 | -# and handlers gated by this feature land in subsequent RFCs (0002+). | |
| 46 | -experimental-fediverse = [ | |
| 47 | - "rustwarden-core/experimental-fediverse", | |
| 46 | +# Federation UI handlers, routes, templates, and i18n strings. | |
| 47 | +# Enables the dependency on `rustwarden-fediverse` (which itself lives in | |
| 48 | +# the workspace). | |
| 49 | +fediverse = [ | |
| 50 | + "dep:rustwarden-fediverse", | |
| 48 | 51 | "dep:futures", |
| 49 | 52 | ] |
| 50 | 53 |
M
crates/rustwarden-ui/locales/en.yml
+2
-0
@@ -253,6 +253,8 @@ admin:
| 253 | 253 | settings_card_desc: "Configure global application settings" |
| 254 | 254 | apps_card_title: "Application Management" |
| 255 | 255 | apps_card_desc: "Enable or disable modular applications" |
| 256 | + fediverse_card_title: "ActivityPub Federation" | |
| 257 | + fediverse_card_desc: "Settings, trust list, outbound queue, remote sessions, NodeInfo" | |
| 256 | 258 | orgs_card_title: "Organisation Management" |
| 257 | 259 | orgs_card_desc: "Create and manage organisations and their members" |
| 258 | 260 | orgs_stat_title: "Organisations" |
M
crates/rustwarden-ui/locales/fr.yml
+2
-0
@@ -254,6 +254,8 @@ admin:
| 254 | 254 | settings_card_desc: "Configurer les paramètres globaux de l'application" |
| 255 | 255 | apps_card_title: "Gestion des Applications" |
| 256 | 256 | apps_card_desc: "Activer ou désactiver les applications modulaires" |
| 257 | + fediverse_card_title: "Fédération ActivityPub" | |
| 258 | + fediverse_card_desc: "Réglages, liste de confiance, file d'envoi, sessions distantes, NodeInfo" | |
| 257 | 259 | orgs_card_title: "Gestion des Organisations" |
| 258 | 260 | orgs_card_desc: "Créer et gérer les organisations et leurs membres" |
| 259 | 261 | orgs_stat_title: "Organisations" |
M
crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+8
-7
@@ -29,19 +29,20 @@ use rustwarden_core::i18n::extractor::Locale;
| 29 | 29 | use rustwarden_core::i18n::service::I18nService; |
| 30 | 30 | use rustwarden_core::middleware::csrf; |
| 31 | 31 | use rustwarden_core::services::app_settings_service::AppSettingsService; |
| 32 | -use rustwarden_core::services::fediverse_trust_service::{ | |
| 32 | +use rustwarden_fediverse::services::fediverse_trust_service::{ | |
| 33 | 33 | BlockSeverity, FediverseTrustService, TrustMode, |
| 34 | 34 | }; |
| 35 | 35 | |
| 36 | -use rustwarden_core::models::fediverse_outbound_queue; | |
| 37 | -use rustwarden_core::models::remote_identity; | |
| 36 | +use rustwarden_fediverse::models::fediverse_outbound_queue; | |
| 37 | +use rustwarden_fediverse::models::remote_identity; | |
| 38 | 38 | use rustwarden_core::models::user as user_model; |
| 39 | -use rustwarden_core::models::{fediverse_pending_invite, team}; | |
| 40 | -use rustwarden_core::services::fediverse_invite_service::{ | |
| 39 | +use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 40 | +use rustwarden_core::models::team; | |
| 41 | +use rustwarden_fediverse::services::fediverse_invite_service::{ | |
| 41 | 42 | FediverseInviteService, InviteStatus, |
| 42 | 43 | }; |
| 43 | 44 | use rustwarden_core::services::team_service::TeamRole; |
| 44 | -use rustwarden_core::services::fediverse_outbound_service::OutboundStatus; | |
| 45 | +use rustwarden_fediverse::services::fediverse_outbound_service::OutboundStatus; | |
| 45 | 46 | use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect}; |
| 46 | 47 | |
| 47 | 48 | use crate::dto::NavigationDTO; |
@@ -692,7 +693,7 @@ pub async fn add_trust_entry(
| 692 | 693 | // instance »). The middleware F10 check then rejects all |
| 693 | 694 | // future protected requests carrying a JWT bound to the |
| 694 | 695 | // disabled identity. |
| 695 | - use rustwarden_core::services::fediverse_trust_service::normalize_domain; | |
| 696 | + use rustwarden_fediverse::services::fediverse_trust_service::normalize_domain; | |
| 696 | 697 | use sea_orm::sea_query::Expr; |
| 697 | 698 | let now = chrono::Utc::now(); |
| 698 | 699 | let normalized = normalize_domain(domain); |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+12
-0
@@ -213,6 +213,11 @@ struct AdminDashboardTemplate {
| 213 | 213 | settings_card_desc: String, |
| 214 | 214 | apps_card_title: String, |
| 215 | 215 | apps_card_desc: String, |
| 216 | + // Federation card (only displayed when the `fediverse` Cargo feature | |
| 217 | + // is compiled into rustwarden-ui). | |
| 218 | + has_fediverse: bool, | |
| 219 | + fediverse_card_title: String, | |
| 220 | + fediverse_card_desc: String, | |
| 216 | 221 | } |
| 217 | 222 | |
| 218 | 223 | rustwarden_core::impl_template_response!(AdminDashboardTemplate); |
@@ -361,6 +366,13 @@ pub async fn admin_dashboard(
| 361 | 366 | apps_card_desc: i18n_service |
| 362 | 367 | .translate("admin.dashboard.apps_card_desc", locale_str) |
| 363 | 368 | .await, |
| 369 | + has_fediverse: cfg!(feature = "fediverse"), | |
| 370 | + fediverse_card_title: i18n_service | |
| 371 | + .translate("admin.dashboard.fediverse_card_title", locale_str) | |
| 372 | + .await, | |
| 373 | + fediverse_card_desc: i18n_service | |
| 374 | + .translate("admin.dashboard.fediverse_card_desc", locale_str) | |
| 375 | + .await, | |
| 364 | 376 | }; |
| 365 | 377 | |
| 366 | 378 | Ok(template) |
M
crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs
+9
-9
@@ -27,20 +27,20 @@ use rustwarden_core::error::AppError;
| 27 | 27 | use rustwarden_core::i18n::extractor::Locale; |
| 28 | 28 | use rustwarden_core::i18n::service::I18nService; |
| 29 | 29 | use rustwarden_core::auth::jwt::Claims; |
| 30 | -use rustwarden_core::services::fediverse_auth_service::{ | |
| 30 | +use rustwarden_fediverse::services::fediverse_auth_service::{ | |
| 31 | 31 | build_auth_decision_payload, ChallengeDecision, ChallengeStatus, FediverseAuthService, |
| 32 | 32 | InitiateRequest, |
| 33 | 33 | }; |
| 34 | -use rustwarden_core::services::fediverse_invite_service::FediverseInviteService; | |
| 35 | -use rustwarden_core::models::fediverse_pending_invite; | |
| 36 | -use rustwarden_core::services::fediverse_trust_service::FediverseTrustService; | |
| 37 | -use rustwarden_core::services::fediverse_outbound_service::{ | |
| 34 | +use rustwarden_fediverse::services::fediverse_invite_service::FediverseInviteService; | |
| 35 | +use rustwarden_fediverse::models::fediverse_pending_invite; | |
| 36 | +use rustwarden_fediverse::services::fediverse_trust_service::FediverseTrustService; | |
| 37 | +use rustwarden_fediverse::services::fediverse_outbound_service::{ | |
| 38 | 38 | EnqueueRequest, FediverseOutboundService, |
| 39 | 39 | }; |
| 40 | -use rustwarden_core::config::FediverseConfig; | |
| 41 | -use rustwarden_core::models::remote_identity; | |
| 40 | +use rustwarden_fediverse::FediverseConfig; | |
| 41 | +use rustwarden_fediverse::models::remote_identity; | |
| 42 | 42 | |
| 43 | -use rustwarden_core::models::fediverse_auth_challenge; | |
| 43 | +use rustwarden_fediverse::models::fediverse_auth_challenge; | |
| 44 | 44 | use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; |
| 45 | 45 | |
| 46 | 46 | /// Default challenge TTL for the AP-native auth flow (RFC 0006 §6). |
@@ -891,7 +891,7 @@ pub async fn invite_landing_form(
| 891 | 891 | .unwrap_or_else(|| invite.team_id.to_string()); |
| 892 | 892 | |
| 893 | 893 | // Status terminal? |
| 894 | - use rustwarden_core::services::fediverse_invite_service::InviteStatus; | |
| 894 | + use rustwarden_fediverse::services::fediverse_invite_service::InviteStatus; | |
| 895 | 895 | let status_parsed = InviteStatus::parse(&invite.status); |
| 896 | 896 | let already_decided = status_parsed != InviteStatus::Pending; |
| 897 | 897 |
M
crates/rustwarden-ui/src/handlers/mod.rs
+2
-2
@@ -6,11 +6,11 @@
| 6 | 6 | pub mod admin_handler; |
| 7 | 7 | /// Admin handlers for the Fediverse foundations (RFC 0021): runtime |
| 8 | 8 | /// settings page (`/admin/fediverse/settings`). |
| 9 | -#[cfg(feature = "experimental-fediverse")] | |
| 9 | +#[cfg(feature = "fediverse")] | |
| 10 | 10 | pub mod admin_fediverse; |
| 11 | 11 | /// User-facing handlers for the Fediverse auth flow (RFC 0023, Slice B): |
| 12 | 12 | /// error landing, login form, waiting page, consent screen. |
| 13 | -#[cfg(feature = "experimental-fediverse")] | |
| 13 | +#[cfg(feature = "fediverse")] | |
| 14 | 14 | pub mod fediverse_user_auth; |
| 15 | 15 | /// Dashboard widget handlers. |
| 16 | 16 | pub mod dashboard_handler; |
M
crates/rustwarden-ui/src/routes.rs
+3
-3
@@ -60,7 +60,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 60 | 60 | .route("/login/2fa", get(login_handler::login_2fa_page)); |
| 61 | 61 | |
| 62 | 62 | // Fediverse public auth routes (RFC 0023 Slice B). Gated by feature. |
| 63 | - #[cfg(feature = "experimental-fediverse")] | |
| 63 | + #[cfg(feature = "fediverse")] | |
| 64 | 64 | let public_routes = { |
| 65 | 65 | use crate::handlers::fediverse_user_auth; |
| 66 | 66 | public_routes |
@@ -138,7 +138,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 138 | 138 | // and `/profile/connected_apps` are both intercepted by |
| 139 | 139 | // `AuthService` via their respective prefixes so `Claims` is |
| 140 | 140 | // injected before the handler runs. |
| 141 | - #[cfg(feature = "experimental-fediverse")] | |
| 141 | + #[cfg(feature = "fediverse")] | |
| 142 | 142 | let ui_routes = { |
| 143 | 143 | use crate::handlers::fediverse_user_auth; |
| 144 | 144 | ui_routes |
@@ -193,7 +193,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 193 | 193 | // Fediverse admin routes (RFC 0021 Slice A) — gated by feature so |
| 194 | 194 | // apps without `experimental-fediverse` keep their attack surface |
| 195 | 195 | // unchanged. |
| 196 | - #[cfg(feature = "experimental-fediverse")] | |
| 196 | + #[cfg(feature = "fediverse")] | |
| 197 | 197 | let admin_routes = { |
| 198 | 198 | use crate::handlers::admin_fediverse; |
| 199 | 199 | admin_routes |
M
crates/rustwarden-ui/templates/admin/dashboard.html
+12
-0
@@ -135,6 +135,18 @@
| 135 | 135 | </div> |
| 136 | 136 | </a> |
| 137 | 137 | |
| 138 | + {% if has_fediverse %} | |
| 139 | + <a href="/admin/fediverse/settings" class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow"> | |
| 140 | + <div class="card-body p-4"> | |
| 141 | + <h2 class="card-title text-info text-base">🌐 {{ fediverse_card_title }}</h2> | |
| 142 | + <p class="text-sm">{{ fediverse_card_desc }}</p> | |
| 143 | + <div class="card-actions justify-end mt-2"> | |
| 144 | + <button class="btn btn-info btn-sm">{{ access_button }}</button> | |
| 145 | + </div> | |
| 146 | + </div> | |
| 147 | + </a> | |
| 148 | + {% endif %} | |
| 149 | + | |
| 138 | 150 | </div> |
| 139 | 151 | </div> |
| 140 | 152 | {% endblock %} |
M
tests/fediverse-testbed/Dockerfile
+1
-1
@@ -15,7 +15,7 @@ COPY tools ./tools
| 15 | 15 | |
| 16 | 16 | # rustwarden-app + migrate (SeaORM CLI) + seed helper. |
| 17 | 17 | RUN cargo build --release \ |
| 18 | - -p rustwarden-app --features experimental-fediverse \ | |
| 18 | + -p rustwarden-app --features fediverse \ | |
| 19 | 19 | && cargo build --release \ |
| 20 | 20 | -p rustwarden-core --bin seed --bin migrate |
| 21 | 21 |
M
tests/fediverse-testbed/README.md
+52
-1
@@ -111,6 +111,57 @@ cargo test --features ... # itère
| 111 | 111 | ./scripts/start.sh # reprend plus tard |
| 112 | 112 | ``` |
| 113 | 113 | |
| 114 | +## Rebuild après modification du code source | |
| 115 | + | |
| 116 | +`setup.sh` exécute `docker compose up -d --wait` qui **ne reconstruit pas** l'image si elle existe déjà dans le cache local. Quand tu modifies du code Rust (workspace `crates/*`, `app/*`) et que tu veux que le testbed reflète ces changements, force le rebuild : | |
| 117 | + | |
| 118 | +```bash | |
| 119 | +cd tests/fediverse-testbed | |
| 120 | + | |
| 121 | +# 1. Arrêter et purger les containers + volumes actuels | |
| 122 | +./scripts/teardown.sh | |
| 123 | + | |
| 124 | +# 2. Forcer un rebuild de l'image avant le up | |
| 125 | +docker compose build --no-cache | |
| 126 | +# (ou `docker compose build` si tu veux laisser cargo réutiliser ses caches via le BuildKit) | |
| 127 | + | |
| 128 | +# 3. Relancer la séquence setup complète (compose up + migrate + seed + trust) | |
| 129 | +./scripts/setup.sh | |
| 130 | + | |
| 131 | +# 4. Vérifier que les 3 instances sont healthy | |
| 132 | +docker compose ps | |
| 133 | + | |
| 134 | +# 5. Configurer le trust croisé (rejoué automatiquement par setup.sh mais | |
| 135 | +# explicite si tu fais le rebuild en plusieurs étapes) | |
| 136 | +./scripts/configure-trust.sh | |
| 137 | + | |
| 138 | +# 6. Smoke runtime côté hôte | |
| 139 | +curl -s 'http://localhost:3001/.well-known/webfinger?resource=acct:joanie@alpha.local' | jq | |
| 140 | +curl -s 'http://localhost:3002/.well-known/nodeinfo' | jq | |
| 141 | +curl -s 'http://localhost:3003/nodeinfo/2.1' | jq | |
| 142 | + | |
| 143 | +# 7. Lancer la suite d'intégration Rust contre le testbed | |
| 144 | +# IMPORTANT : `--test-threads=1` est obligatoire — les tests E2E | |
| 145 | +# mutent l'état partagé du testbed (trust list, kill_domain, | |
| 146 | +# remote_identities.disabled_at, fediverse_trust_mode) et entrent | |
| 147 | +# en collision quand cargo les lance en parallèle (5 failures | |
| 148 | +# "fantômes" sur les scénarios S3/S4/S5/S6 sinon). | |
| 149 | +cd ../.. | |
| 150 | +cargo test -p rustwarden-fediverse --test e2e_fediverse -- --test-threads=1 | |
| 151 | +cargo test -p rustwarden-fediverse --test fediverse_interop -- --test-threads=1 | |
| 152 | + | |
| 153 | +# 8. Inspecter les logs si quelque chose coince | |
| 154 | +cd tests/fediverse-testbed | |
| 155 | +docker compose logs app-alpha | tail -50 | |
| 156 | +docker compose logs app-beta | tail -50 | |
| 157 | +docker compose logs app-gamma | tail -50 | |
| 158 | + | |
| 159 | +# 9. Arrêter le testbed quand fini | |
| 160 | +./scripts/teardown.sh | |
| 161 | +``` | |
| 162 | + | |
| 163 | +Le rebuild Rust dans le container prend **5–10 min** la première fois (la couche de deps reste cachée tant que `Cargo.lock` ne change pas — voir limite "build à froid long" ci-dessous). | |
| 164 | + | |
| 114 | 165 | ## Limites connues |
| 115 | 166 | |
| 116 | 167 | - HTTP plain only. Le flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` autorise le bypass HTTPS en local — ne **jamais** activer en production. |
@@ -120,4 +171,4 @@ cargo test --features ... # itère
| 120 | 171 | |
| 121 | 172 | ## Plan d'évolution |
| 122 | 173 | |
| 123 | -Itération 1+ : tests E2E Rust (`cargo test --test e2e_fediverse`) qui pilotent le testbed via les ports `localhost:300X` exposés. Code dans `crates/rustwarden-core/tests/e2e_fediverse/` (à créer, hors périmètre de cette itération). | |
| 174 | +Itération 1+ : tests E2E Rust (`cargo test --test e2e_fediverse`) qui pilotent le testbed via les ports `localhost:300X` exposés. Code dans [`crates/rustwarden-fediverse/tests/e2e_fediverse.rs`](../../crates/rustwarden-fediverse/tests/e2e_fediverse.rs) et [`crates/rustwarden-fediverse/tests/fediverse_interop.rs`](../../crates/rustwarden-fediverse/tests/fediverse_interop.rs) — déplacés depuis `rustwarden-core` lors de l'extraction du crate fédération (cf. `docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md`). |
GitRust