auth-middleware-layering.md 9316 octets

Middleware d'auth : résolution de l'AuthConfig et ordre des layers

Mode Diátaxis : explanation (comprendre la mécanique) + un mini how-to de troubleshooting à la fin. Référence pour les consommateurs de rustwarden-core qui mappent leurs propres routes auth-required ou composent leur pile tower à la main.

Pourquoi cette page existe

Trois RFC se sont succédé pour stabiliser les middlewares AuthService et AdminService :

Les deux premières corrigeaient un symptôme : l'ordre des layers tower configuré par défaut dans app.rs place AdminService et AuthService avant le layer Extension(Arc<AuthConfig>), donc au moment où ces middlewares s'exécutent l'extension n'est pas encore dans request.extensions(). Sans fallback, le résultat était soit une erreur 500 silencieuse (avant RFC 0022) soit des claims = None silencieux (avant RFC 0031).

Mais le fallback avait un effet de bord : from_env() ne connaît pas les overrides du builder. .login_path(..), .auth_failure_mode(..) et la bascule Json401 de .headless() n'atteignaient jamais les deux middlewares. La RFC 0042 traite la cause : depuis 1.2.0, chaque middleware porte l'Arc<AuthConfig> résolue par RustwardenBuilder::build().

Résolution de la configuration (depuis 1.2.0)

Les deux middlewares résolvent l'AuthConfig dans cet ordre :

  1. Configuration capturée à la construction (with_auth_config). C'est la voie canonique : build() charge l'environnement, applique les overrides du builder, puis passe l'Arc aux constructeurs. Aucune dépendance à l'ordre des layers.
  2. Extension(Arc<AuthConfig>) lue dans la requête — chemin legacy, uniquement si le middleware a été construit avec new (déprécié).
  3. AuthConfig::from_env() — chemin legacy de dernier recours, même condition. Un tracing::warn! est émis une fois par processus pour signaler l'usage de l'API dépréciée.

Sur le chemin canonique, le middleware pose lui-même l'Arc capturé dans request.extensions() avant de passer la main. Les extracteurs internes (AuthUser, auth_required, admin_required, admin_helper) voient donc exactement la même instance, y compris dans une pile composée à la main sans layer Extension. Dans le câblage canonique, le layer Extension(Arc<AuthConfig>) d'app.rs réinsère ensuite le même Arc : c'est un no-op volontairement conservé.

Précédence des overrides

RustwardenBuilder::build() résout le mode d'échec ainsi :

.auth_failure_mode(..)  >  AUTH_FAILURE_MODE (env)  >  bascule .headless() (Json401)  >  Redirect

.login_path(..) prime sur LOGIN_PATH, qui prime sur /login. Une valeur d'environnement explicite n'est pas un « défaut » : elle l'emporte sur la bascule headless, ce qui permet un rollback sans redéploiement de code.

Les deux branches de AuthService

AuthService::call choisit son comportement en fonction du préfixe de chemin demandé :

BrancheCondition d'entréeModeRéaction si JWT absent / invalide
Protectedpath commence par un préfixe de protected_prefixesAuthentification obligatoireauth_failure_mode : 303 vers login_path?redirect=… ou 401 {"error":"unauthorized"}
Optional authpath ne matche aucun préfixe protégéAuthentification best-effortAucune action ; la requête traverse avec claims = None

Dans les deux branches, dès qu'un JWT est présent, valide et non blacklisté, les claims sont injectés dans request.extensions() :

  • Extension<Claims> (struct fortement typée)
  • Extension<String> (JSON sérialisé, utile pour les templates Askama)

AdminService applique la même logique, avec vérification du rôle admin, sur /admin* et /api/v1/admin*.

Composer sa propre pile tower

use std::sync::Arc;
use rustwarden_core::config::auth::AuthConfig;
use rustwarden_core::middleware::{admin_service::AdminService, auth_service::AuthService};
use tower::ServiceBuilder;

let auth_config = Arc::new(AuthConfig::from_env()?);
let prefixes = Arc::new(vec!["/api/private".to_string()]);
let app = router.layer(
    ServiceBuilder::new()
        .layer_fn({
            let (db, cfg) = (db.clone(), auth_config.clone());
            move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone())
        })
        .layer_fn({
            let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone());
            move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone())
        })
        .into_inner(),
);
// Pas besoin de poser Extension(Arc<AuthConfig>) soi-même : le middleware le fait.

Exemple complet et compilable : app/examples/custom_auth_stack.rs.

Quand ai-je besoin de register_protected_prefix() ?

Le RustwardenBuilder expose register_protected_prefix(prefix) pour ajouter un chemin à la liste protected_prefixes. Deux raisons légitimes de l'utiliser :

  1. Rejeter automatiquement les non-authentifiés sur certaines routes, selon auth_failure_mode. Sans préfixe protégé, un visiteur anonyme atteindra le handler et il devra refuser lui-même via Extension<Claims> (qui provoque un 400 Bad Request axum) ou un check explicite.

  2. Activer les vérifications additionnelles (must-change-password, 2FA forcé global, blacklist JWT) qui ne tournent que sur la branche protected.

À l'inverse, pas besoin de déclarer un préfixe protégé juste pour que les claims soient injectés : depuis RFC 0031, la branche optional suffit.

Troubleshooting

« Mon app headless répond 303 vers /login au lieu de 401 »

Symptôme du bug corrigé par la RFC 0042 : avant 1.2.0, la bascule Json401 de .headless() n'atteignait pas les middlewares. Depuis 1.2.0, headless() produit bien un 401 sauf si AUTH_FAILURE_MODE ou .auth_failure_mode(..) disent autre chose. Si vous êtes encore sur une version antérieure : posez AUTH_FAILURE_MODE=json401 dans l'environnement.

« Mon app headless + SSR répond 401 au lieu de rediriger vers /login »

Conséquence attendue de la RFC 0042 pour une app .headless() qui comptait sur l'ancien comportement (bug). Deux remèdes :

  • dans le code : .auth_failure_mode(AuthFailureMode::Redirect) sur le builder ;
  • sans redéploiement : AUTH_FAILURE_MODE=redirect dans l'environnement.

« .login_path("/auth/signin") est ignoré »

Avant 1.2.0 : bug RFC 0042, seule la variable LOGIN_PATH était lue par les middlewares. Depuis 1.2.0 : vérifiez que vous passez par RustwardenApp::builder() ou, si vous composez votre pile, que vous utilisez with_auth_config et non new (déprécié : cherchez le warning deprecated constructor path dans les logs au premier appel).

« Login → /dashboard redirige vers /login sans erreur »

Symptôme historique du bug RFC 0031 : le cookie JWT est bien posé, le user est authentifié, mais /dashboard (ou toute route hors des préfixes protégés) redirige vers /login.

  1. Confirmer que le cookie est transmis :
    curl -v -b /tmp/cookies.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|cookie"
    
  2. Vérifier les logs serveur pour JWT extracted from cookie.
  3. Vérifier la version de rustwarden-core : antérieure à RFC 0031 → bumper, ou déclarer register_protected_prefix("/dashboard") comme contournement.

« Le serveur renvoie 500 sur /admin sans log clair »

Sur une pile composée avec new (déprécié) et sans JWT_SECRET : AuthConfig::from_env() échoue. Le log AdminService: AuthConfig unavailable (no extension and from_env failed) précise l'erreur. Passez à with_auth_config : la configuration est validée une fois au démarrage et ce chemin disparaît.

Voir aussi