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-corequi 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.subpointe surusers.idouremote_identities.id; - doit faire un
find_by_idpuis unfind_by_idcroisé 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 lesubcollisionne avec unuser_idlocal 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 = Localinjecté 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 = Localimplicite. - 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_jsonactuel est tolérant (pas dedeny_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 :
subdoit rester un UUID. - Hijacks
audqui 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 :
default_identity_kind_is_local—IdentityKind::default()=Local.claims_serialise_kind_as_snake_case— champ wire"kind": "remote".claims_deserialise_legacy_token_as_local— JSON sanskind→IdentityKind::Local.claims_remote_id_skips_when_none— sérialisation ometremote_idsiNone.is_remote_returns_true_for_remote_token— happy path.is_local_returns_true_when_default— happy path.remote_identity_id_returns_none_for_local— guard helper.generate_remote_token_round_trip— signe viaJwtService::generate_remote_token, valide viaJwtService::validate_token, assertclaims.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_kindcôté Rust pour clarté. -
Position du helper : sur
Claimsdirectement (pas une struct wrapper) → API plus discoverable. -
Sécurité :
is_remote()doit retournerfalsesiremote_idestNonemême quandkind = Remote? Décision : non, on respecte lekind; un token mal forméRemotesansremote_idest rejeté plus haut, à l'émission. Test 7 verrouille le contrat opposé.
Tracking issue
À ouvrir au moment de la PR.
Score d'évaluation
| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 4 | Pattern réutilisable au-delà du fediverse pour toute origine externe (SAML, mTLS) à l'avenir. |
| Stabilité API | 5 | Additif, défaut, pas de breaking. |
| Performance | 4 | +60 bytes par token, négligeable ; supprime un DB lookup au check d'autorisation. |
| Sécurité | 5 | Hardening net : empêche la confusion local/remote dans les middlewares. |
| Complexité d'implémentation | 4 | ~150 lignes (enum + 2 champs + helpers + 8 tests). |
| Maintenance | 3 | Champ permanent dans le format JWT — toute évolution ultérieure devra le respecter. |
| Documentation | 2 | Rustdoc EN, doctest, CHANGELOG ; pas encore d'examples/ complet. |
| Total | 27/35 | ≥ 25 → Accepted |
GitRust