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(&current) {
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(&current) {
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`).