0007-jwt-claims-identity-kind.md 9873 octets

RFC 0007 — Fediverse: extension JWT Claims (identity_kind, remote_id)

  • Auteur : EBO (rustwarden)
  • Date : 2026-05-09
  • Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 27/35)
  • Apps consommatrices concernées : toute app utilisant rustwarden-core qui fait dépendre des décisions d'autorisation de la nature locale ou distante du sujet du JWT.
  • Plan associé : docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 2 — fondation auth AP-native.
  • Dépendances : RFC 0003 (remote-identity-store).

Summary

Étendre Claims avec deux champs optionnels — identity_kind: IdentityKind (Local par défaut, Remote pour les sessions issues d'un IdP fediverse) et remote_id: Option<Uuid> (FK vers remote_identities.id quand kind = Remote) — pour que les middlewares d'autorisation puissent distinguer un user local d'un user remote sans relire la DB.

Motivation

À partir de la Phase 2, un JWT pourra être émis pour un sujet distant (alice@a.tld connectée sur b.tld). Sans champ discriminant, le code consommateur :

  • ne sait pas si Claims.sub pointe sur users.id ou remote_identities.id ;
  • doit faire un find_by_id puis un find_by_id croisé sur deux tables pour le retrouver, ce qui annule l'avantage du JWT (claims auto-portants) ;
  • expose un risque de confused-deputy : un attaquant pourrait forger un JWT remote dont le sub collisionne avec un user_id local et obtenir un rôle global.

Sans cette RFC, le team_service (RFC 0009) et le middleware admin_required ne savent pas refuser l'accès « rôle global » aux remote users sans aller-retour DB.

Non-goals

  • Pas de modification du flow d'émission existant pour les users locaux (rétrocompat 100 % : identity_kind = Local injecté par défaut côté désérialisation et émission).
  • Pas de rotation/migration des JWT déjà émis avant la livraison — les sessions actives restent valides, simplement avec kind = Local implicite.
  • Pas de changement de l'algorithme HS256 ni du format de signature.
  • Pas de gestion du flow Accept{AuthRequest} lui-même — c'est RFC 0006.

API proposée

Nouvelle énumération

/// Discriminant porté par chaque [`Claims`] : nature du sujet du JWT.
///
/// Persisté dans le payload sous la clé `kind` pour rester compact.
/// Compat ascendante : un JWT existant sans cette clé est lu comme
/// `IdentityKind::Local`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityKind {
    /// Sujet = `users.id` (compte local).
    Local,
    /// Sujet = `remote_identities.id` (utilisateur AP fédéré sur cette
    /// instance via le flow `AuthRequest`/`Accept` de la RFC 0006).
    Remote,
}

impl Default for IdentityKind {
    fn default() -> Self { Self::Local }
}

Extension de Claims

Deux nouveaux champs publics, tous deux #[serde(default)] pour garantir la compatibilité ascendante avec les JWT existants :

pub struct Claims {
    // … champs existants (sub, jti, aud, username, roles, permissions, exp, iat, iss) …

    /// Identity kind: `Local` for `users.id`, `Remote` for
    /// `remote_identities.id`. Older tokens (pre-RFC 0007) deserialise
    /// to `Local`.
    #[serde(default, rename = "kind")]
    pub identity_kind: IdentityKind,

    /// FK vers `remote_identities.id` quand `identity_kind = Remote`,
    /// `None` sinon (incluant `Local` et tokens pré-RFC).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub remote_id: Option<Uuid>,
}

Helpers d'API

Sur Claims directement (pas une nouvelle méthode statique de JwtService), pour rester explicites côté call-site :

impl Claims {
    /// Returns true when this token represents a remote (federated) identity.
    pub fn is_remote(&self) -> bool {
        matches!(self.identity_kind, IdentityKind::Remote)
    }

    /// Returns true when this token represents a local user.
    pub fn is_local(&self) -> bool {
        matches!(self.identity_kind, IdentityKind::Local)
    }

    /// `Some(id)` only when this is a remote token; `None` for local.
    pub fn remote_identity_id(&self) -> Option<Uuid> {
        if self.is_remote() { self.remote_id } else { None }
    }
}

Surcharge de JwtService::generate_token

L'API existante reste rétrocompatible (signature inchangée, défaut Local). Une nouvelle entrée explicite est ajoutée pour le flow remote :

impl JwtService {
    /// Generate a JWT token explicitly typed as a remote identity. The
    /// `sub` claim becomes the `remote_identities.id` and `remote_id`
    /// is set to the same value for ergonomics.
    pub fn generate_remote_token(
        config: &AuthConfig,
        remote_identity_id: Uuid,
        handle: &str,
    ) -> Result<String, AppError>;
}

handle est la chaîne @user@domain — utilisée comme username côté payload pour cohérence avec les logs.

Exemple d'usage côté consommateur

use rustwarden_core::auth::jwt::{IdentityKind, JwtService};

// Émission par le service auth fediverse (RFC 0006).
let token = JwtService::generate_remote_token(&config, remote.id, "alice@a.tld")?;

// Côté middleware admin_required (RFC 0008).
if claims.is_remote() {
    return Err(AppError::Authorization(
        "remote identities cannot hold global roles".into(),
    ));
}

Analyse de compatibilité

  • Non-breaking : champs ajoutés avec #[serde(default)]. Les JWT pré-RFC se désérialisent correctement (identity_kind = Local).
  • Pas de changement de schéma DB.
  • Format sérialisé étendu : ancien décodeur stricte (qui rejetterait des champs inconnus) plante — vérifié que le décodeur serde_json actuel est tolérant (pas de deny_unknown_fields).
  • Aucune méthode publique existante n'est supprimée.

Alternatives considérées

Alternative A (recommandée) — Champs additifs sur Claims + helpers

Pros :

  • Tout le contexte d'autorisation reste dans le payload, zéro DB lookup.
  • Compatible ascendante.
  • Permet aux middlewares de short-circuiter avant DB.

Cons : 2 champs supplémentaires augmentent légèrement la taille du payload (~60 bytes encodés base64).

Alternative B — Table de mapping (jti, identity_kind) séparée

Pros : payload reste compact.

Cons :

  • DB lookup à chaque requête → ruine l'avantage du JWT.
  • Augmente la complexité du blacklist (déjà 1 lookup par requête).

Rejeté.

Alternative C — Discriminer via aud ou un préfixe sur sub

Pros : pas de nouveau champ.

Cons :

  • Hacky : sub doit rester un UUID.
  • Hijacks aud qui sert l'isolation multi-app.

Rejeté.

Alternative D — Ne rien faire

Cons : RFC 0006 (auth flow) n'a pas de place où stocker l'origine. Le code consommateur doit faire un cross-table lookup à chaque check.

Rejeté.

Testing plan

8 tests :

  1. default_identity_kind_is_local — IdentityKind::default() = Local.
  2. claims_serialise_kind_as_snake_case — champ wire "kind": "remote".
  3. claims_deserialise_legacy_token_as_local — JSON sans kind → IdentityKind::Local.
  4. claims_remote_id_skips_when_none — sérialisation omet remote_id si None.
  5. is_remote_returns_true_for_remote_token — happy path.
  6. is_local_returns_true_when_default — happy path.
  7. remote_identity_id_returns_none_for_local — guard helper.
  8. generate_remote_token_round_trip — signe via JwtService::generate_remote_token, valide via JwtService::validate_token, assert claims.is_remote() + claims.sub == remote_id.

Doctest : 1 exemple complet d'émission + validation remote.

Migration guide

### Added

- `Claims.identity_kind: IdentityKind` (`Local` par défaut).
- `Claims.remote_id: Option<Uuid>`.
- `JwtService::generate_remote_token(...)` pour les sessions fédérées.

### Migration steps

1. Aucune action requise pour les apps locales : les tokens existants
   continuent d'être validés et le défaut est `IdentityKind::Local`.
2. Les apps qui implémentent leurs propres middlewares peuvent ajouter
   un check `claims.is_remote()` avant tout chemin réservé aux
   utilisateurs locaux (rôles globaux, panneau admin).

Open questions

Toutes résolues à l'auto-revue :

  • Naming kind : court et explicite côté JSON ; identity_kind côté Rust pour clarté.
  • Position du helper : sur Claims directement (pas une struct wrapper) → API plus discoverable.
  • Sécurité : is_remote() doit retourner false si remote_id est None même quand kind = Remote ? Décision : non, on respecte le kind ; un token mal formé Remote sans remote_id est rejeté plus haut, à l'émission. Test 7 verrouille le contrat opposé.

Tracking issue

À ouvrir au moment de la PR.


Score d'évaluation

CritèreNote (1-5)Commentaire
Généralité4Pattern réutilisable au-delà du fediverse pour toute origine externe (SAML, mTLS) à l'avenir.
Stabilité API5Additif, défaut, pas de breaking.
Performance4+60 bytes par token, négligeable ; supprime un DB lookup au check d'autorisation.
Sécurité5Hardening net : empêche la confusion local/remote dans les middlewares.
Complexité d'implémentation4~150 lignes (enum + 2 champs + helpers + 8 tests).
Maintenance3Champ permanent dans le format JWT — toute évolution ultérieure devra le respecter.
Documentation2Rustdoc EN, doctest, CHANGELOG ; pas encore d'examples/ complet.
Total27/35≥ 25 → Accepted