RFC 0006 — Fediverse: flow d'authentification AP-native (couche service)
- Auteur : EBO (rustwarden)
- Date : 2026-05-09
- Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 26/35)
- Apps consommatrices concernées : toute app utilisant
rustwarden-corequi veut permettre à un utilisateur d'un IdP fediverse externe de se connecter et obtenir un JWT remote. - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 2 — pièce maîtresse. - Dépendances : RFC 0001, RFC 0003, RFC 0004, RFC 0007, RFC 0011.
Summary
Introduire la table fediverse_auth_challenges (state machine Pending → Approved | Rejected | Expired | Consumed) et le service FediverseAuthService qui orchestre le flow client-side : génération du challenge nonce, persistance, vérification du Accept{AuthRequest} reçu, création de session JWT remote. La construction de l'AuthRequest à signer puis l'enqueue dans fediverse_outbound_queue (RFC 0011) est exposée via un helper public, mais la mise en place des routes HTTP (initiate / callback / status / consent / inbox) est explicitement reportée à une RFC suivante (0006-bis ou intégrée à la Phase 2 finale UI). Cette RFC livre la couche service complète + tests qui permettront aux routes HTTP futures d'être un simple câblage.
Motivation
Sans cette couche service, RFC 0009 (invitations cross-instance) et RFC 0010 (signaux lifecycle) n'ont aucune session remote sur laquelle s'appuyer pour autoriser/refuser des actions. Le master plan §10 Phase 2 indique 5 endpoints HTTP, mais la complexité utile est dans le service — l'HTTP est plomberie. En livrant le service avec une suite de tests verts, on dérisque la Phase 2 et on permet aux RFCs en aval de progresser.
Sans cette RFC, on ne peut pas tester le « happy path » deux instances locales sans monter tout le stack HTTP.
Non-goals
- Pas de routes HTTP dans cette RFC. Aucune entrée
/api/v1/auth/login/fediverse/*n'est exposée. Les apps consommatrices peuvent appeler le service depuis leur propre handler dans l'intervalle. - Pas de UI HTML (consent screen, waiting page) —
rustwarden-uitraitera dans Phase 2 finale. - Pas de signature HTTP réelle sortante — déléguée à
OutboundDelivery(RFC 0011 livre leNoopDelivery, RFC 0010 leHttpSignedDelivery). - Pas de vérification de signature entrante sur l'
Accept{AuthRequest}reçu — clairement marqué comme dette technique fermée par RFC 0010 ; la fonction de vérification est isolée derrière un trait pluggable. - Pas de polling/SSE côté client — méthode publique
statussynchrone ; les routes HTTP futures peuvent l'envelopper en long-poll si besoin. - Pas de gestion du cas où la réponse arrive avant l'expiration de la session de l'utilisateur sur l'IdP — TTL fixe 5 min suffisant pour le slice.
API proposée
Migration m20260510_000013_fediverse_auth_challenges
CREATE TABLE fediverse_auth_challenges ( id UUID PRIMARY KEY, nonce TEXT NOT NULL UNIQUE, handle TEXT NOT NULL, -- @user@domain demandé domain TEXT NOT NULL, requested_remote_id UUID NULL, -- FK remote_identities, set quand connu status TEXT NOT NULL DEFAULT 'pending', -- pending|approved|rejected|expired|consumed redirect_url TEXT NULL, -- callback de l'app consommatrice created_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ NULL ); CREATE INDEX fediverse_auth_challenges_pending_idx ON fediverse_auth_challenges (status, expires_at) WHERE status = 'pending';
Énumération + types
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChallengeStatus { Pending, Approved, Rejected, Expired, Consumed } #[derive(Debug, Clone)] pub struct InitiateRequest { pub handle: String, // "alice@a.tld" pub redirect_url: Option<String>, } #[derive(Debug, Clone)] pub struct InitiatedChallenge { pub id: Uuid, pub nonce: String, // 256-bit URL-safe base64 pub expires_at: DateTime<Utc>, }
Service
pub struct FediverseAuthService; impl FediverseAuthService { /// Step 1 — caller resolved a handle and now wants to start a flow. /// Persists a `pending` challenge with a fresh nonce. Caller is /// responsible for enqueueing the `AuthRequest` activity to the /// remote inbox via `FediverseOutboundService::enqueue` and the /// helper [`build_auth_request_payload`] below. pub async fn initiate( db: &DatabaseConnection, request: InitiateRequest, ttl: Duration, ) -> Result<InitiatedChallenge, AppError>; /// Step 2 — IdP-side: user clicked Approve / Reject. Updates the /// challenge status and (for Approve) records the remote_identity_id. /// Idempotent: a second call with the same id returns `AlreadyDecided`. pub async fn record_decision( db: &DatabaseConnection, challenge_id: Uuid, decision: ChallengeDecision, ) -> Result<fediverse_auth_challenges::Model, AppError>; /// Step 3 — caller polls until decision arrives. Returns the /// challenge row, with status reflecting the latest known value. /// Marks expired challenges if `expires_at < now`. pub async fn status( db: &DatabaseConnection, challenge_id: Uuid, ) -> Result<fediverse_auth_challenges::Model, AppError>; /// Step 4 — caller exchanges an `Approved` challenge for a remote /// JWT (via `JwtService::generate_remote_token`). Atomically flips /// status to `Consumed` (one-shot) so the same Accept can never /// mint two tokens. pub async fn consume( db: &DatabaseConnection, config: &AuthConfig, challenge_id: Uuid, ) -> Result<ConsumedSession, AppError>; } #[derive(Debug, Clone)] pub enum ChallengeDecision { Approve { remote_identity_id: Uuid }, Reject, } #[derive(Debug, Clone)] pub struct ConsumedSession { pub jwt: String, pub remote_identity_id: Uuid, pub handle: String, }
Helper de construction du payload
/// Build the JSON-LD body of the `AuthRequest` activity addressed at /// `target_inbox`. The body is unsigned at this stage; the outbound /// worker (RFC 0010 transport) attaches the HTTP-Sig at delivery time. pub fn build_auth_request_payload( config: &FediverseConfig, challenge: &InitiatedChallenge, target_handle: &str, ) -> serde_json::Value;
Analyse de compatibilité
- Non-breaking : nouvelle table + nouveau service derrière feature.
- Migration up/down réversible.
- N'altère aucune API publique existante.
-
Le JWT remote émis utilise
JwtService::generate_remote_token(RFC 0007), donc cohérent avec la blacklist existante.
Alternatives considérées
Alternative A (recommandée) — Service complet, routes HTTP différées
Pros : permet aux RFCs aval de progresser ; couvre la complexité métier réelle ; routes HTTP triviales ensuite.
Cons : un consommateur souhaitant le flow end-to-end aujourd'hui doit écrire ses 4 handlers HTTP.
Alternative B — RFC monolithique service + routes HTTP + UI HTML
Cons : ~2000 lignes, surface de revue énorme, blocage des autres RFCs.
Rejeté.
Alternative C — Stocker le challenge en mémoire (pas de table)
Cons : perte au redémarrage du worker, pas multi-instance.
Rejeté.
Alternative D — Réutiliser le mécanisme OAuth state existant
Cons : sémantique différente (OAuth → IdP géré ; ici IdP est l'AP server distant) ; cumul des risques de sécurité.
Rejeté.
Testing plan
12 tests :
Pure :
challenge_status_round_trips_through_string.build_auth_request_payload_includes_nonce_and_target.build_auth_request_payload_uses_fediverse_base_url.
DB-mocked (sea-orm/mock) :
4. initiate_persists_pending_challenge_with_fresh_nonce.
5. initiate_returns_unique_nonces_across_calls.
6. record_decision_approve_marks_approved_and_records_remote_id.
7. record_decision_reject_marks_rejected.
8. status_marks_expired_when_past_expires_at.
9. status_returns_pending_when_not_decided_yet.
10. consume_rejects_pending_challenge.
11. consume_rejects_already_consumed_challenge.
12. consume_emits_remote_jwt_for_approved_challenge.
Migration guide
### Added (rustwarden-core, feature `experimental-fediverse`) - Migration `m20260510_000013_fediverse_auth_challenges`. - `FediverseAuthService` (initiate / record_decision / status / consume). - Helper `build_auth_request_payload`. ### Migration steps 1. `cargo run --bin migrate`. 2. App consommatrice : écrire les 4 handlers HTTP (initiate, status, consent, callback) en s'appuyant sur le service. Un exemple complet sera fourni dans un follow-up RFC dédié aux routes.
Open questions
Toutes résolues à l'auto-revue :
- Nonce length : 32 bytes URL-safe base64 (≈43 chars).
-
TTL par défaut : 5 min (
fediverse.auth_challenge_ttl_secs). -
Consumedvs blacklist du JWT :consumemarque la ligne comme consumed et émet le JWT en une transaction logique ; toute future tentative de consume de la même ligne retourneAlreadyConsumed.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | State machine challenge réutilisable au-delà du fediverse (ex. magic links). |
| Stabilité API | 4 | Additif, gated. |
| Performance | 4 | Lookups indexés, table petite (challenges éphémères). |
| Sécurité | 4 | Nonce cryptographique, one-shot consume, expiration stricte. La signature HTTP entrante reste à RFC 0010 — explicitement marqué. |
| Complexité d'implémentation | 3 | ~700 lignes (migration + entité + service + helper + 12 tests). |
| Maintenance | 3 | State machine claire, peu de cas edge. |
| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
| Total | 26/35 | ≥ 25 → Accepted |
GitRust