RFC 0025 — Fediverse: back-channel Accept(AuthRequest) (RFC 0006 Phase 3)
- Auteur : EBO (rustwarden)
- Date : 2026-05-11
- Statut : Accepted — implémentation livrée 2026-05-11 (5 tests E2E + 348 unit verts, suite E2E complète à 14)
- Apps consommatrices concernées : toute app utilisant
rustwarden-corequi souhaite réellement compléter un round-trip d'authentification fédérée Rustwarden ↔ Rustwarden. - Plan associé :
docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md(Finding F3). - Dépendances : RFC 0006 (Phase 1 & 2), RFC 0011 (outbound worker), RFC 0015 (HTTP signatures), RFC 0016 (signed delivery).
- Supersedes : aucun (complète RFC 0006).
Summary
Implémenter le back-channel manquant du flow d'authentification AP-native : quand l'IdP (instance A) approuve ou rejette une AuthRequest, propager la décision vers le SP (instance B) via une activité AP standard Accept / Reject dont le champ object pointe l'AuthRequest originale. Côté B, l'inbox dispatche sur type et bascule le challenge local en Approved / Rejected, ce qui débloque consume() qui peut alors émettre le JWT remote.
Motivation
L'évaluation E2E (testbed tests/fediverse-testbed/, itération 1c) a démontré que deux instances Rustwarden ne peuvent pas effectivement compléter un SSO bilatéral avec le code actuel :
- ✅
B → A: l'AuthRequestsigné est reçu et stocké en challenge Pending côté A. — RFC 0006 Phase 1 + 2. - ✅
A → A: l'utilisateur clique « Approve » dans l'UI A, le challenge passe à Approved côté A. — RFC 0023 Slice B. - ❌
A → B: aucun mécanisme. L'auth_inboxne dispatche pas surtype,consent_submitn'enqueue rien dansfediverse_outbound_queue. Le challenge sur B reste éternellement Pending jusqu'au TTL 5 min.
Conséquence : bloquant pour la promotion experimental-fediverse → fediverse stable. Sans round-trip, le protocole n'est pas opérationnel et aucune deuxième instance Rustwarden ne peut être un partenaire fonctionnel.
La RFC 0006 originale référence explicitement Accept{AuthRequest} dans son Summary mais n'a jamais livré l'implémentation côté inbox B. Cette RFC clôt cette dette.
Non-goals
- Pas de polling / long-polling / SSE côté
consume()— le client polling au niveau de l'app consommatrice reste la responsabilité de l'UI (RFC 0023 Slice B garde son SSE). - Pas de modification du flow
B → Ainitial —AuthRequestreste un type custom dans le namespace Rustwarden, traité par le chemin actuel. - Pas de nouveau type d'activité custom — explicitement, on n'invente pas
AuthApproval/AuthDenial; on utilise les types AP standardsAccept/Reject. - Pas d'interop Mastodon — un Mastodon recevant un
Accept(AuthRequest)l'ignorera silencieusement (pas de side-effect connu). C'est le comportement souhaité : l'isolation du protocole Rustwarden ↔ Rustwarden reste intacte. - Pas de re-signature des activités existantes dans le code (aucun
AuthApprovaln'a été émis en prod jusqu'à présent). - Pas de migration
cavage-draft→ RFC 9421 des HTTP Signatures (sera traité dans une RFC séparée, cf. § Alternatives).
API proposée
1. Dispatch type-based dans auth_inbox
crates/rustwarden-core/src/fediverse/auth_routes.rs:156
Le handler actuel assume AuthRequest. Le nouveau code dispatche :
async fn auth_inbox( Extension(state): Extension<AuthFlowState>, Extension(db): Extension<DatabaseConnection>, headers: HeaderMap, body: axum::body::Bytes, ) -> Result<StatusCode, FediverseError> { // ... vérification signature (inchangé) ... let body_json = serde_json::from_slice::<serde_json::Value>(&body) .map_err(|e| FediverseError::BadRequest(format!("malformed JSON: {e}")))?; let activity_type = body_json .get("type") .and_then(|v| v.as_str()) .unwrap_or(""); let idp_domain = normalised_idp_domain(); match activity_type { "AuthRequest" => { FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain) .await .map_err(|e| FediverseError::BadRequest(e.to_string()))?; } "Accept" | "Reject" => { FediverseAuthService::record_inbound_decision(&db, &body_json) .await .map_err(|e| FediverseError::BadRequest(e.to_string()))?; } other => { // AP spec §7 : types non reconnus sont ignorés silencieusement. tracing::debug!(activity_type = other, "auth_inbox: ignoring unknown type"); } } Ok(StatusCode::ACCEPTED) }
2. Nouveau service record_inbound_decision
crates/rustwarden-core/src/services/fediverse_auth_service.rs :
impl FediverseAuthService { /// Phase 3 — IdP (A) → SP (B) back-channel. /// /// Called by B's `/fediverse/auth/inbox` when A delivers an /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's /// `object` IRI references B's original AuthRequest, and we use the /// embedded nonce (last URL segment) to look up B's local challenge. /// /// Idempotent: replaying the same decision returns Ok without DB write. pub async fn record_inbound_decision( db: &DatabaseConnection, body: &serde_json::Value, ) -> Result<(), AppError> { let activity_type = body .get("type") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("activity missing `type`".into()))?; let nonce = extract_nonce_from_object(body)?; let approver_actor_url = body .get("actor") .and_then(|v| v.as_str()) .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))? .to_string(); let challenge = fediverse_auth_challenge::Entity::find() .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce)) .one(db) .await? .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?; if challenge.status != ChallengeStatus::Pending.as_str() { // Idempotence : replay sur challenge déjà décidé → ok silencieux. return Ok(()); } if challenge.expires_at < Utc::now() { return Err(AppError::Validation("challenge expired".into())); } // Résoudre l'approver côté B en `remote_identities`. Le verifier // upstream a déjà créé/mis à jour la ligne (cf. F2). let approver = remote_identity::Entity::find() .filter(remote_identity::Column::ApId.eq(&approver_actor_url)) .one(db) .await? .ok_or_else(|| AppError::Validation(format!( "approver {approver_actor_url} not in remote_identities cache" )))?; let new_status = match activity_type { "Accept" => ChallengeStatus::Approved, "Reject" => ChallengeStatus::Rejected, other => return Err(AppError::Validation(format!("unexpected type {other}"))), }; let mut active: fediverse_auth_challenge::ActiveModel = challenge.into(); active.status = Set(new_status.as_str().to_string()); active.requested_remote_id = Set(Some(approver.id)); active.update(db).await?; Ok(()) } } /// Extract the nonce from the `object` field. Supports both string-IRI /// and inline-object shapes (`object: "https://b/auth/req/<nonce>"` vs /// `object: { "id": "...", "type": "AuthRequest", "nonce": "..." }`). fn extract_nonce_from_object(body: &serde_json::Value) -> Result<String, AppError> { let object = body.get("object").ok_or_else(|| { AppError::Validation("Accept/Reject activity missing `object`".into()) })?; if let Some(s) = object.as_str() { // IRI form: last segment is the nonce. return Ok(s.rsplit('/').next().unwrap_or("").to_string()); } if let Some(inline) = object.as_object() && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str()) { return Ok(nonce.to_string()); } Err(AppError::Validation( "cannot extract nonce from Accept/Reject object".into(), )) }
3. Enqueue outbound dans consent_submit
crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs:578 — après record_decision, enqueue le Accept ou Reject sortant.
// Après FediverseAuthService::record_decision(&db, challenge_id, decision).await?; // Charger le challenge pour récupérer le requester remote_identity. let model = FediverseAuthService::status(&db, challenge_id).await?; let requester_id = model .requested_remote_id .ok_or_else(|| AppError::Internal("challenge has no requester binding".into()))?; let requester = RemoteIdentityService::find_by_id(&db, requester_id).await?; let approver_user = UserService::find_by_id(&db, claims.user_id()).await?; let approver_actor_url = format!("{}users/{}", config.base_url(), approver_user.username); let original_auth_request_iri = format!("{}auth-requests/{}", requester.base_url_or_homepage(), model.nonce); let payload = json!({ "@context": [ "https://www.w3.org/ns/activitystreams", { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } ], "type": if matches!(decision, ChallengeDecision::Approve { .. }) { "Accept" } else { "Reject" }, "id": format!("{}auth-decisions/{}", config.base_url(), uuid::Uuid::new_v4()), "actor": approver_actor_url, "object": original_auth_request_iri, "to": [ requester.inbox_url.clone() ], }); FediverseOutboundService::enqueue( &db, OutboundEnqueueRequest { target_inbox: requester.inbox_url.clone(), actor_user_id: Some(approver_user.id), payload_json: serde_json::to_string(&payload)?, activity_kind: ActivityKind::AuthDecision, }, ).await?;
4. Payload exact
Accept(AuthRequest) — approbation
{ "@context": [ "https://www.w3.org/ns/activitystreams", { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } } ], "type": "Accept", "id": "http://alpha.local/auth-decisions/0d23-...", "actor": "http://alpha.local/users/joanie", "object": "http://beta.local/auth-requests/<nonce>", "to": ["http://beta.local/users/joanie/inbox"] }
Reject(AuthRequest) — refus
{ "type": "Reject", "id": "http://alpha.local/auth-decisions/...", "actor": "http://alpha.local/users/joanie", "object": "http://beta.local/auth-requests/<nonce>", "to": ["http://beta.local/users/joanie/inbox"] }
Le champ object est une IRI string (forme légère), pas un objet inline. Le nonce de B est extrait du dernier segment de l'URL — c'est ce que la fonction extract_nonce_from_object fait avec un fallback objet inline pour robustesse.
5. Tests E2E à ajouter
À ajouter à crates/rustwarden-core/tests/e2e_fediverse.rs :
| Test | Couvre |
|---|---|
s1_full_round_trip_signed_e2e | Happy path : B initiate → POST signé vers A → consent API JSON → A enqueue Accept → polling B's consume → 200 + JWT remote |
s1_reject_propagates_to_b | Idem mais decision=reject → B's consume retourne 4xx |
s1_unknown_nonce_rejected_silently | A poste Accept avec nonce inconnu → B retourne 202 (silent ignore) ou 4xx (cf. § Décisions) |
s1_expired_challenge_rejects_late_accept | Accept arrive après TTL → erreur Validation |
s1_replay_accept_is_idempotent | Rejouer le même Accept → 202, pas de double-write DB |
Analyse de compatibilité
-
API existante non modifiée —
auth_inboxsignature publique inchangée, ajouts derrière la couche service. -
Pas de changement de schéma DB —
fediverse_auth_challengesetremote_identitiessont déjà en place. -
Pas de breaking change feature-flag — toujours sous
experimental-fediverse. - Pas d'activité AP custom émise jusque-là, donc aucune compat ascendante à maintenir.
- Migration sérialisée : aucune.
→ Non-breaking. Minor version bump possible.
Alternatives considérées
Alternative A (recommandée) : Accept / Reject AP standards avec object = IRI de l'AuthRequest
Pros :
- Convention AP universelle (Mastodon utilise
Accept(Follow)/Reject(Follow)exactement comme ça pour les demandes d'amitié, cf. Mastodon AP spec § Follow). - Spec W3C explicite (
Accept/Rejectpolymorphes selonobject.type, ActivityPub §7.6-7.7). - Pas de nouveau type custom à documenter ni à protéger contre la sérialisation parasite par AP-strict servers.
- Mastodon ignore silencieusement → isolation propre du protocole Rustwarden.
- Dispatch côté B trivial : un seul
matchsurtype, déjà familier au reste de l'écosystème.
Cons :
- Lecture du payload nécessite un fallback IRI/inline pour le
object(légèrement plus de code quebody.noncedirect). - Sémantique « Accept » peut sembler bizarre pour de l'auth (vs. Follow) — atténué par le namespace clair de l'
object.
Alternative B : Activités custom AuthApproval / AuthDenial
Pros :
- Sémantique plus directement lisible côté code.
- Champ
nonceau niveau racine sans extraction depuisobject.
Cons :
- Invente un type AP non-standard alors qu'un type standard existe et correspond.
- Toute implémentation tierce stricte (validateurs JSON-LD) requiert le
@contextcomplet documenté, sinon le terme est silencieusement perdu — coût de maintenance. - Mastodon le rejette comme inconnu → log noise sur tous les serveurs tiers même si interop n'est pas un goal.
- Pas dans la lignée de ce que la RFC 0006 originale a écrit dans son Summary (
Accept{AuthRequest}est explicitement mentionné).
Verdict : rejetée.
Alternative C : Polling B → A (GET /fediverse/auth/status/{nonce})
Pros :
- Pas de signature à émettre côté A.
- B contrôle son propre rythme de polling.
- Pas de side-effect inbox.
Cons :
- Charge réseau prévisible mais O(N) par challenge ouvert (B doit poller chaque challenge non résolu).
- Latence de notification = intervalle de poll.
- Casse le modèle AP-push qui structure le reste de Rustwarden Fediverse.
- N'aide pas pour l'extension future (
Update Person,Delete Person, RFC 0010) qui nécessite le push de toute façon.
Verdict : rejetée (moins propre que A, et n'aide pas pour la suite).
Alternative D : Long-polling / SSE B → A
Pros :
- Latence quasi-nulle.
Cons :
- Connexions persistantes coûteuses (potentiellement N par utilisateur sur l'IdP).
- Ajout d'infrastructure (timeouts, gestion déconnexions) pour une fonctionnalité que push AP gère gratuitement.
- Non-aligned avec le reste du protocole.
Verdict : rejetée.
Alternative E : Ne rien faire (statu quo)
Pros : pas de code à écrire.
Cons :
experimental-fediversene peut pas être promu stable. Le SSO bilatéral est l'objectif #1 du master plan, et il ne fonctionne pas.- Tout futur consommateur découvrira le trou en testant et le signalera comme bug critique.
Verdict : rejetée.
Sécurité
Replay protection
Une activité Accept interceptée et rejouée :
- Hors fenêtre Date (>30s, cf. RFC 0015) → rejetée par le verifier de signature.
- Dans la fenêtre, sur un challenge déjà
Approved→ idempotent (cf. § record_inbound_decision), aucun side-effect. - Dans la fenêtre, sur un challenge déjà
Consumed→ idempotent. - Sur un nonce qui n'existe pas → 4xx (cf. test
s1_unknown_nonce_rejected_silently).
Pour la sécurité accrue (challenge nonce single-use + stockage seen_accept_ids), une suite F3-bis pourrait ajouter une table fediverse_auth_decisions_seen indexée par Accept.id. Hors scope cette RFC.
Authentification de l'approver
record_inbound_decision se contente de retrouver le remote_identities row du actor de l'Accept — le verifier de signature en amont garantit que la clé qui a signé correspond au domaine de cet actor. Donc :
- Un attaquant sur
evil.tldne peut pas émettre unAccept(http://beta.local/auth-requests/<nonce>)valide pour B parce que sa signature serait signée parevil.tld, mais l'IdP autorisé estalpha.local— mismatch détecté. - Plus précisément : B doit vérifier que l'
actorduAcceptest sur le même domaine que l'IdP qui a reçu l'AuthRequest. Cette vérification est ajoutée àrecord_inbound_decision(extension simple : recharger le challenge, comparerchallenge.domainau domaine de l'actor).
Note sur RFC 9421
Mastodon est passé de draft-cavage à RFC 9421 (Signature-Input + Signature) en v4.5.0 (Mastodon Security spec). Cette RFC continue d'utiliser draft-cavage comme partout dans le projet ; une RFC séparée traitera la migration vers RFC 9421 pour l'ensemble du fediverse Rustwarden.
Décisions explicites à noter
- Le
objectest une IRI string par défaut (pas un inline object) — réduit le verbosité et matche ce que Mastodon fait. Fallback inline pour robustesse défensive. - Nonce extrait du dernier segment de l'URL — convention
http://b.tld/auth-requests/<nonce>. Documenter et exposer cette URL dansbuild_auth_request_payload(RFC 0006 ligne 354, déjà compatible : le champidy est déjà construit comme{base_url}auth-requests/{challenge.id}, à modifier pour utiliser le nonce). - Replay sur challenge décidé → 202 silent (idempotence) plutôt que 4xx. AP-friendly : tout outbound worker qui retry n'a pas à savoir si c'est la 1re ou la 3e tentative.
- Replay sur challenge expiré → 4xx plutôt que 202. Décision claire : l'expiry est une condition métier, pas une erreur transitoire.
- Nonce inconnu → 4xx Validation plutôt que ignore silent. Aide au debug (logs explicites) et ne permet pas d'utiliser l'inbox comme oracle pour des nonces existants (le timing reste constant : query DB unique dans les deux cas).
Plan d'implémentation
Étape 1 — Service record_inbound_decision
- Implémentation + tests unitaires
MockDatabase(5 tests : happy path, idempotence approved/consumed, unknown nonce, expired, type mismatch). - ~120 LoC code + 80 LoC tests.
Étape 2 — Dispatch type-based dans auth_inbox
- Refactor du handler avec
match. - Adapter le test existant
auth_inbox_returns_400_when_requester_actor_unknownqui ne touche que la branche AuthRequest. - ~50 LoC.
Étape 3 — Build payload helper
build_auth_decision_payload(config, challenge, approver_actor, decision)retournant le JSON.- Tests unitaires : structure JSON-LD, contexte, object IRI bien formé.
- ~80 LoC.
Étape 4 — Enqueue dans consent_submit UI
- Wire l'enqueue après
record_decision. - Test unitaire (UI handler) avec MockDatabase qui vérifie l'enqueue.
- ~60 LoC.
Étape 5 — 5 tests E2E nouveaux dans e2e_fediverse.rs
- ~300 LoC.
Total estimé : ~700 LoC. ~1.5 jour de développement + tests.
Critères d'acceptation
-
Test
s1_full_round_trip_accept_marks_b_challenge_approvedvert sans aucun DB-direct côté B. -
Test
s1_reject_propagates_and_blocks_consumevert. -
Test
s1_replay_accept_is_idempotentvert (assertion viaxminPostgreSQL). -
Test
s1_expired_challenge_rejects_late_acceptvert. -
Test bonus
s1_unknown_nonce_is_rejectedvert. -
Quality gates standards : build clean, clippy
-D warnings, doc clean, tests unitaires verts (348/348). - Mise à jour CHANGELOG.md.
- Plan parent iter1-findings.md marque F3 = ✅.
-
Doc opérateur (futur RFC) mentionne que
Accept(AuthRequest)est le format de décision attendu. (reporté hors-scope cette RFC)
Références
- W3C ActivityPub Recommendation, §7.6 Accept, §7.7 Reject
- Mastodon ActivityPub specification — Follow approval flow
- Mastodon Security specification — HTTP Signatures, fenêtre Date
- SWICG HTTP Signatures guide — pratiques 2025
- SocialHub: Single Sign-On for Fediverse thread — état du non-consensus
- RFC 0006 — flow d'authentification AP-native (couche service, Phase 1 + 2)
- RFC 0011 — outbound queue
- RFC 0015 — HTTP signatures
GitRust