0025-fediverse-auth-back-channel.md 21423 octets

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-core qui 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'AuthRequest signé 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_inbox ne dispatche pas sur type, consent_submit n'enqueue rien dans fediverse_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 → A initial — AuthRequest reste 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 standards Accept / 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 AuthApproval n'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 :

TestCouvre
s1_full_round_trip_signed_e2eHappy path : B initiate → POST signé vers A → consent API JSON → A enqueue Accept → polling B's consume → 200 + JWT remote
s1_reject_propagates_to_bIdem mais decision=reject → B's consume retourne 4xx
s1_unknown_nonce_rejected_silentlyA poste Accept avec nonce inconnu → B retourne 202 (silent ignore) ou 4xx (cf. § Décisions)
s1_expired_challenge_rejects_late_acceptAccept arrive après TTL → erreur Validation
s1_replay_accept_is_idempotentRejouer le même Accept → 202, pas de double-write DB

Analyse de compatibilité

  • API existante non modifiée — auth_inbox signature publique inchangée, ajouts derrière la couche service.
  • Pas de changement de schéma DB — fediverse_auth_challenges et remote_identities sont 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 / Reject polymorphes selon object.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 match sur type, 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 que body.nonce direct).
  • 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 nonce au niveau racine sans extraction depuis object.

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 @context complet 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-fediverse ne 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.tld ne peut pas émettre un Accept(http://beta.local/auth-requests/<nonce>) valide pour B parce que sa signature serait signée par evil.tld, mais l'IdP autorisé est alpha.local — mismatch détecté.
  • Plus précisément : B doit vérifier que l'actor du Accept est 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, comparer challenge.domain au 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

  1. Le object est 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.
  2. Nonce extrait du dernier segment de l'URL — convention http://b.tld/auth-requests/<nonce>. Documenter et exposer cette URL dans build_auth_request_payload (RFC 0006 ligne 354, déjà compatible : le champ id y est déjà construit comme {base_url}auth-requests/{challenge.id}, à modifier pour utiliser le nonce).
  3. 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.
  4. Replay sur challenge expiré → 4xx plutôt que 202. Décision claire : l'expiry est une condition métier, pas une erreur transitoire.
  5. 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_unknown qui 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_approved vert sans aucun DB-direct côté B.
  • Test s1_reject_propagates_and_blocks_consume vert.
  • Test s1_replay_accept_is_idempotent vert (assertion via xmin PostgreSQL).
  • Test s1_expired_challenge_rejects_late_accept vert.
  • Test bonus s1_unknown_nonce_is_rejected vert.
  • 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