API d'authentification JWT
Le module JWT autonome signe et vérifie un seul token. Une vraie
API a besoin de tout le cycle de vie : un token d'accès à courte durée, un
token de rafraîchissement à longue durée, la rotation au rafraîchissement, et
la révocation pour la déconnexion. Rustango fournit tout cela sous la
forme de JwtLifecycle — et un routeur clé en main qui monte pour vous
POST /api/auth/login, /refresh, /logout, et GET /me.
Source :
rustango::tenancy::jwt_lifecycle(JwtLifecycle,JwtTokenPair,JwtClaims) etrustango::tenancy::auth_routes(jwt_router,Config) +rustango::jti_store(JtiStore,InMemoryJtiStore) — derrièrejwt+tenancy.Version exécutable : le moteur de tokens est couvert par le test
auth_demo—cargo test -p auth_demo --test auth_jwt_api. Les endpoints HTTP sont cloisonnés par tenant et éprouvés de bout en bout par le proprecrates/rustango/tests/tenant_auth_live.rsdu framework.
Un terme vous est inconnu ? token d'accès/de rafraîchissement, rotation, révocation — voir le glossaire.
Complément approfondi de la section « Émettre et rafraîchir des JWT » du Guide de sécurité. Pour un token unique géré manuellement, voir plutôt JWT (autonome).
Table des matières
- Le routeur intégré · Le câblage
- Le moteur de tokens · Rafraîchissement et rotation
- Révocation et le magasin de JTI · Claims personnalisés
- Notes et limites
Le routeur intégré
jwt_router monte les quatre endpoints standard sur la table rustango_users
propre à chaque tenant — les ~50 lignes de boilerplate de login que tout projet
réécrit sinon :
| Méthode | Chemin | Corps / Auth | Renvoie |
|---|---|---|---|
| POST | /api/auth/login | {username, password} | {access, refresh, user} |
| POST | /api/auth/refresh | {refresh} | {access, refresh} |
| POST | /api/auth/logout | Authorization: Bearer <access> + {refresh} optionnel | 204 (révoque les deux JTI) |
| GET | /api/auth/me | Authorization: Bearer <access> | {user_id, username, is_superuser} |
Login vérifie le mot de passe avec argon2id, puis émet une
paire. Les chemins, TTL et la clé de signature sont configurables via Config.
Le câblage
use rustango::tenancy::auth_routes::{jwt_router, Config};
rustango::manage::Cli::new()
.tenancy()
.api(my_app::urls::api()
.merge(jwt_router(Config::default()))) // monte /api/auth/*
.run()
.await
Config::default() signe avec RUSTANGO_SESSION_SECRET (la même clé que le
cookie de session admin) et utilise des TTL de 15 min pour l'accès / 7 jours pour
le rafraîchissement. Redéfinissez prefix, access_ttl_secs,
refresh_ttl_secs, ou session_secret selon vos besoins. Les endpoints
s'exécutent dans le contexte du tenant, montez-les donc dans une application
tenancy.
# Login → access + refresh
curl -sX POST localhost:8080/api/auth/login \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"hunter2hunter"}'
# Appeler un endpoint protégé
curl localhost:8080/api/auth/me -H "Authorization: Bearer $ACCESS"
Le moteur de tokens (JwtLifecycle)
Sous le routeur se trouve JwtLifecycle — utilisable directement si vous voulez
le cycle de vie sans la forme HTTP intégrée :
use rustango::tenancy::jwt_lifecycle::JwtLifecycle;
let jwt = JwtLifecycle::new(secret_32_bytes);
// Login : émettre la paire.
let pair = jwt.issue_pair(user_id);
// → pair.access (TTL court, à envoyer dans l'en-tête Authorization)
// → pair.refresh (TTL long, à stocker dans un cookie HttpOnly / stockage sécurisé)
// Requête authentifiée : vérifier le token d'accès.
match jwt.verify_access(&access).await {
Some(claims) => { /* claims.sub est l'id utilisateur */ }
None => { /* 401 : invalide, expiré, révoqué, ou mauvais type */ }
}
Les tokens d'accès et de rafraîchissement ne sont pas interchangeables —
verify_access rejette un token de rafraîchissement et vice versa, de sorte
qu'un token d'accès à courte durée volé ne peut pas servir à en forger de
nouveaux :
let pair = jwt.issue_pair(42);
assert!(jwt.verify_refresh(&pair.access).await.is_none());
assert!(jwt.verify_access(&pair.refresh).await.is_none());
Rafraîchissement et rotation
refresh échange un token de rafraîchissement valide contre une nouvelle
paire et met en liste noire le JTI de l'ancien token de rafraîchissement —
expiration glissante avec des tokens de rafraîchissement à usage unique (le
rejeu de l'ancien est refusé) :
let pair = jwt.issue_pair(7);
let rotated = jwt.refresh(&pair.refresh).await.expect("refresh ok");
assert_ne!(pair.access, rotated.access);
assert!(jwt.refresh(&pair.refresh).await.is_none()); // l'ancien refresh est maintenant mort
Par défaut, refresh préserve les claims personnalisés du token. Si les
permissions ont pu changer (rôle révoqué, portée réduite), utilisez
refresh_with(token, new_claims) pour substituer un payload frais tout en
mettant quand même en liste noire l'ancien JTI de rafraîchissement.
Révocation et le magasin de JTI
Chaque token porte un jti unique. revoke l'ajoute à une liste noire de sorte
que les appels verify_* suivants échouent jusqu'à ce que le token ait de toute
façon expiré — c'est ce qu'appelle POST /api/auth/logout :
let pair = jwt.issue_pair(1);
assert!(jwt.revoke(&pair.access).await);
assert!(jwt.verify_access(&pair.access).await.is_none());
Envoyez le token de rafraîchissement à /logout
Ne révoquer que le bearer met fin à un token qui aurait de toute façon expiré en quelques minutes. Le token de rafraîchissement est celui qui vit des jours, et il peut émettre de nouveaux tokens d'accès pendant toute sa TTL — une déconnexion qui le laisse en vie ne termine donc pas la session, elle la reporte :
POST /api/auth/logout
Authorization: Bearer <access>
{ "refresh": "<refresh>" } // révoque aussi la moitié à longue durée
Le corps est optionnel, donc les clients écrits pour l'ancien endpoint continuent de fonctionner sans changement — ils révoquent simplement moins. Envoyez-le. Les deux moitiés sont rattachées au locataire appelant, de sorte qu'un sous-domaine ne peut pas révoquer le token d'un autre.
La liste noire réside dans un JtiStore interchangeable. Le InMemoryJtiStore
par défaut est mono-processus et perd les révocations au redémarrage —
convient pour une seule instance. Tout déploiement multi-réplica DOIT installer
un magasin partagé et durable (Redis / BD) pour qu'une déconnexion sur une
réplica soit honorée par toutes :
use rustango::jti_store::{InMemoryJtiStore, JtiStore};
use std::sync::Arc;
let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new()); // à remplacer par Redis en prod
let a = JwtLifecycle::new(secret.clone()).with_jti_store(Arc::clone(&shared));
let b = JwtLifecycle::new(secret).with_jti_store(Arc::clone(&shared));
let pair = a.issue_pair(5);
a.revoke(&pair.access).await;
assert!(b.verify_access(&pair.access).await.is_none()); // B voit la révocation de A
Sans magasin partagé,
/logoutest au mieux « best-effort » : un token révoqué peut encore être accepté sur une autre réplica jusqu'à son expiration naturelle. C'est le paramètre de production le plus important pour l'authentification JWT.
Écrire un magasin durable
JtiStore est asynchrone (depuis v0.52, #1191) : une implémentation durable
tient donc en une requête — pas de vidage en arrière-plan, pas de fenêtre de
convergence pendant laquelle un jti révoqué est encore accepté ailleurs. Les
deux méthodes renvoient JtiFuture<'_, T> (un future boxé — le trait est utilisé
comme Arc<dyn JtiStore>, et un async fn natif dans un trait n'est pas
compatible avec les objets dyn) :
use rustango::jti_store::{JtiFuture, JtiStore};
impl JtiStore for PgJtiStore {
fn is_used<'a>(&'a self, jti: &'a str) -> JtiFuture<'a, bool> {
Box::pin(async move { self.lookup(jti).await })
}
fn mark_used<'a>(&'a self, jti: &'a str, exp_unix: i64) -> JtiFuture<'a, bool> {
Box::pin(async move { self.insert_if_absent(jti, exp_unix).await })
}
}
mark_used DOIT être atomique : parmi des appelants concurrents pour le même
jti, exactement un doit obtenir true — une seule écriture conditionnelle
(INSERT … ON CONFLICT DO NOTHING, Redis SET NX), jamais une lecture suivie
d'une écriture. Sinon la garantie d'usage unique des tokens de rafraîchissement
disparaît.
Comme la vérification consulte le magasin, verify_access, verify_refresh,
refresh, revoke et les helpers de token MCP / tenant
(mcp::verify_agent_token, tenancy::auth_routes::verify_for_tenant) sont tous
async. L'expiration est vérifiée avant le magasin : un token expiré ne
coûte donc aucun aller-retour.
Claims personnalisés
Intégrez roles / tenant / scope directement dans le token pour que la
vérification ne nécessite aucune consultation de BD. Les noms réservés (sub,
exp, jti, typ) sont rejetés :
let custom = serde_json::json!({ "roles": ["admin"], "tenant": "acme" })
.as_object().unwrap().clone();
let pair = jwt.issue_pair_with(99, custom)?;
let claims = jwt.verify_access(&pair.access).await.unwrap();
let roles: Vec<String> = claims.get_custom("roles").unwrap(); // ["admin"]
Les claims personnalisés survivent à refresh (reportés sur la nouvelle paire)
sauf si vous utilisez refresh_with.
Notes et limites
-
Sessions vs JWT vs ceci : un JWT simple ne peut pas être révoqué ; une Session est révocable mais nécessite une consultation du magasin à chaque requête ;
JwtLifecycleest la voie intermédiaire — vérification sans état, plus une liste de blocage JTI pour les révocations dont vous avez réellement besoin (déconnexion, rotation). -
Les endpoints HTTP sont cloisonnés par tenant.
jwt_routerrésout les utilisateurs via le contexte du tenant +rustango_users; montez-le dans une application.tenancy(). Le moteur de tokens (JwtLifecycle) lui-même n'a pas cette exigence. -
Associez ceci au
JwtBackendde la chaîne de backends d'authentification pour authentifier des routes arbitraires à partir de l'en-têteAuthorization: Bearer. -
Signature HS256, plancher de clé de 32 octets — même algorithme et mêmes contraintes que le JWT autonome.
-
Les jetons sont de vrais JWT, donc tout ce qui vérifie un JWT peut vérifier ceux-ci :
jwt.io, la bibliothèque standard de votre plateforme, une passerelle d'API, un autre service auquel vous confiez un jeton. Trois segments, un en-tête JOSE{"alg":"HS256","typ":"JWT"}, signé surheader.payload.Jusqu'à #1397 c'étaient deux segments sans en-tête, signés sur la seule charge utile — lisibles par rien d'autre que rustango, et rejetés jusque par
rustango::jwt::decode. Si vous aviez écrit un vérificateur maison pour contourner cela, vous pouvez le jeter.Les jetons émis avant le correctif restent acceptés à la vérification, pour qu'une mise à niveau ne déconnecte personne. Ce chemin de compatibilité disparaît en 0.58, quand tout jeton à l'ancienne forme aura expiré.
