0006-ap-native-auth-flow.md 10254 octets

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-core qui 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-ui traitera dans Phase 2 finale.
  • Pas de signature HTTP réelle sortante — déléguée à OutboundDelivery (RFC 0011 livre le NoopDelivery, RFC 0010 le HttpSignedDelivery).
  • 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 status synchrone ; 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 :

  1. challenge_status_round_trips_through_string.
  2. build_auth_request_payload_includes_nonce_and_target.
  3. 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).
  • Consumed vs blacklist du JWT : consume marque la ligne comme consumed et émet le JWT en une transaction logique ; toute future tentative de consume de la même ligne retourne AlreadyConsumed.

Tracking issue

À ouvrir au moment de la PR.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4State machine challenge réutilisable au-delà du fediverse (ex. magic links).
Stabilité API4Additif, gated.
Performance4Lookups indexés, table petite (challenges éphémères).
Sécurité4Nonce cryptographique, one-shot consume, expiration stricte. La signature HTTP entrante reste à RFC 0010 — explicitement marqué.
Complexité d'implémentation3~700 lignes (migration + entité + service + helper + 12 tests).
Maintenance3State machine claire, peu de cas edge.
Documentation4Rustdoc EN, doctest, CHANGELOG, migration guide.
Total26/35≥ 25 → Accepted