RFC 0004 — Aligner le fallback AuthConfig::from_env() dans AuthService (branche optional auth)
- Auteur : EBO (gitrust)
- Date : 2026-05-16
- Statut : Accepted (implémenté 2026-05-16)
- Apps consommatrices concernées : gitrust (bug identifié et reproduit). Tout futur consommateur qui expose des routes auth-required hors des
protected_prefixespar défaut du framework. - Crate cible :
rustwarden-core(correction d'un bug interne au middlewareAuthService) - Type : bug-fix RFC (pas de changement d'API publique)
Summary
Aligner le fallback AuthConfig::from_env() entre les deux branches du middleware AuthService (protected_route et optional_auth). Aujourd'hui seule la branche protected applique le fallback, laissant la branche optional dépendre silencieusement de l'ordre des layers tower — ce qui casse l'injection des Claims dans request.extensions() pour toute route consommatrice hors protected_prefixes.
Motivation
Bug observé
Lors de l'intégration fediverse côté gitrust (2026-05-16), un user authentifié avec cookie JWT valide était systématiquement redirigé vers /login?redirect=/dashboard après avoir cliqué "Se connecter", sans erreur visible et sans logs serveur explicites.
Reproduction minimum :
# 1. Reset password via CLI (clear must_change_password) : cargo run -- admin reset-password --username admin # 2. Démarrer le serveur : cargo run # 3. Login via curl : curl -v -c /tmp/c.txt -X POST http://localhost:4000/login \ -d "username=admin&password=PASS" 2>&1 | grep -E "HTTP|location|set-cookie" # < HTTP/1.1 303 See Other # < location: /dashboard # < set-cookie: jwt_token=...; HttpOnly; SameSite=Lax; Path=/ # 4. GET /dashboard avec le cookie : curl -v -b /tmp/c.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|location" # < HTTP/1.1 303 See Other ← LE BUG # < location: /login?redirect=%2Fdashboard
Le cookie est posé, transmis, parsé côté serveur (JWT extracted from cookie dans les logs). Mais le handler dashboard reçoit claims: None → require_auth échoue → redirect /login.
Root cause
Le middleware AuthService (crates/rustwarden-core/src/middleware/auth_service.rs) a deux branches :
Branche protected route (path matche un protected_prefix) — ligne 137-141 :
let auth_config = match parts.extensions.get::<Arc<AuthConfig>>().cloned() { Some(c) => c, None => Arc::new(AuthConfig::from_env().map_err(|e| { tracing::error!("AuthConfig fallback failed: {e}"); AppError::Internal("AuthConfig unavailable".into()) })?), };
Commentaire associé : "the regression commit d4d6828 flipped this path to a hard 500 by mistake" — donc le framework a déjà identifié ce risque et a posé le fallback côté protected.
Branche optional auth (route non-protégée) — ligne 312-321 :
if let Some(auth_config) = parts.extensions.get::<Arc<AuthConfig>>().cloned() { if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) { parts.extensions.insert(claims.clone()); // ... } } // ← Pas de fallback. Si auth_config absent → skip silencieux.
Aucun fallback. Et selon l'ordre des layers tower configuré dans app.rs:556-578 :
ServiceBuilder::new() .layer(AuthService::layer_fn(...)) // outermost (premier inbound) .layer(session_layer) .layer(locale_middleware) .layer(axum::Extension(i18n_service)) .layer(axum::Extension(hooks)) .layer(axum::Extension(auth_config)) // innermost (set après AuthService) .layer(axum::Extension(db_for_ext)) .into_inner()
AuthService s'exécute avant que Extension(AuthConfig) ait posé l'extension. Sur la branche protected, le fallback from_env() sauve la mise. Sur la branche optional, c'est cassé silencieusement.
Impact pour les consommateurs
Tout consommateur qui :
- Active
experimental-fediverseou tout autre crate qui mount des routes auth-required en utilisantOption<Extension<Claims>>côté handler. - Expose des routes auth-required hors des
protected_prefixespar défaut (/home,/profile,/test-base,/settings,/admin).
→ Les claims ne sont jamais injectés. Le user reste invisible. Le handler refuse l'auth. Redirect /login.
Symptôme typique côté consommateur : "le login marche, le cookie est posé, mais l'accès à /dashboard redirige vers /login sans erreur". C'est exactement ce qu'on a observé côté gitrust.
Pourquoi ce n'est pas attrapé par les tests framework
Les tests d'intégration du framework utilisent probablement uniquement les routes protected par défaut. Et le bug est silencieux : pas d'erreur HTTP, pas de log warning. Difficile à détecter sans un consommateur qui mappe ses propres routes auth-required.
Urgence
Modérée. Workaround existe côté consommateur (register_protected_prefix()), mais c'est un piège : tout consommateur futur qui n'a pas conscience de ce bug le rencontrera.
Non-goals
- Ne PAS modifier l'API publique de
AuthService::new. - Ne PAS modifier l'ordre des layers dans
app.rs(changer l'ordre pourrait casser d'autres choses, le fallback est plus sûr). - Ne PAS supprimer la liste
protected_prefixes— elle reste utile pour les routes qui doivent rejeter les non-authentifiés (la branche optional, elle, accepte les non-authentifiés et passe juste des claims=None).
API proposée
Aucun changement d'API publique. Modification interne d'une dizaine de lignes dans crates/rustwarden-core/src/middleware/auth_service.rs.
Avant (l. ~292-326)
} else { // SEC-H9: For non-protected routes, still try to extract and propagate // JWT claims into extensions so that handlers/templates can detect the // logged-in user (optional auth). let db = self.db.clone(); let mut inner = self.inner.clone(); Box::pin(async move { let (mut parts, body) = req.into_parts(); if let Ok(jwt_token) = crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { use crate::auth::jwt::JwtService; use crate::config::auth::AuthConfig; use crate::services::jwt_blacklist_service::JwtBlacklistService; let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token) .await .unwrap_or(true); if !is_blacklisted { if let Some(auth_config) = parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() { if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) { parts.extensions.insert(claims.clone()); if let Ok(json) = serde_json::to_string(&claims) { parts.extensions.insert(json); } } } } } let req = Request::from_parts(parts, body); inner.call(req).await }) }
Après
} else { // SEC-H9: For non-protected routes, still try to extract and propagate // JWT claims into extensions so that handlers/templates can detect the // logged-in user (optional auth). let db = self.db.clone(); let mut inner = self.inner.clone(); Box::pin(async move { let (mut parts, body) = req.into_parts(); if let Ok(jwt_token) = crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { use crate::auth::jwt::JwtService; use crate::config::auth::AuthConfig; use crate::services::jwt_blacklist_service::JwtBlacklistService; let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token) .await .unwrap_or(true); if !is_blacklisted { // Align with the protected branch: when the Extension(AuthConfig) // layer hasn't run yet (due to tower layer ordering), fall back // to AuthConfig::from_env() so the claims can still be validated // and injected. Without this fallback, all routes outside // `protected_prefixes` silently see claims = None. let auth_config = parts .extensions .get::<std::sync::Arc<AuthConfig>>() .cloned() .or_else(|| { AuthConfig::from_env() .map(std::sync::Arc::new) .map_err(|e| { tracing::error!( "AuthService optional_auth: AuthConfig fallback failed: {e}" ); e }) .ok() }); if let Some(auth_config) = auth_config { if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) { parts.extensions.insert(claims.clone()); if let Ok(json) = serde_json::to_string(&claims) { parts.extensions.insert(json); } } } } } let req = Request::from_parts(parts, body); inner.call(req).await }) }
Diff réel : ~12 lignes de Rust ajoutées (l'or_else + le log d'erreur). Le reste du flow est identique.
Analyse de compatibilité
-
API publique non modifiée :
AuthService::new, signatures du middleware, typesClaims— tout reste identique. - Comportement observable : pour les consommateurs qui n'avaient PAS le bug (par chance d'ordre de layers), le comportement est identique. Pour ceux qui l'avaient, les routes optional auth voient enfin les claims.
-
Performance : l'
AuthConfig::from_env()est un parsing env négligeable. Et il n'est appelé qu'une seule fois en cas de fallback (l'extension est posée pour les requêtes suivantes... non, en fait à chaque requête c'est nouveau, donc on évalue à chaque fois). Mesure rapide à faire : < 1µs typiquement. - Schéma DB : pas de changement.
- Format sérialisé : pas de changement.
Verdict : 100 % additif comportemental, bug fix pur.
Alternatives considérées
Alternative A (recommandée) : fallback symétrique dans la branche optional
Voir ci-dessus. Symétrie avec la branche protected. Robuste.
Alternative B : changer l'ordre des layers dans app.rs
Mettre AuthService après les extensions Extension(AuthConfig)/db dans le ServiceBuilder :
Pros : pas de fallback à maintenir, ordre clair.
Cons :
- Risque de side-effects sur d'autres middlewares qui dépendent de l'ordre actuel (session_layer, locale).
- Si un consommateur custom l'ordre via une API future, le bug réapparait. Le fallback est défensif.
- Change le contrat documenté d'ordre des layers — potentiel breaking pour des consommateurs avancés.
Alternative C : forcer AuthConfig dans le state de AuthService::new
Passer Arc<AuthConfig> comme paramètre du constructeur, l'utiliser directement (pas de extensions lookup).
Pros : élimine le risque d'ordre des layers définitivement.
Cons :
- Breaking change sur le constructeur (signature publique modifiée).
- Tous les consommateurs qui appellent
AuthService::newdirectement doivent adapter (probablement peu de consommateurs, mais c'est un changement non-additif). - À garder pour un future major.
Alternative D : ne rien faire (statu quo)
Pros : zéro travail framework.
Cons :
- Chaque consommateur futur qui rencontre ce bug perd 1-2h de debug (cf. session gitrust 2026-05-16).
- Documentation implicite : "tu dois déclarer en
protected_prefixtoute route auth-required" — non-évident. - Mauvaise UX dev framework.
Choix : Alternative A. Bug fix minimal, non-breaking, robuste.
Testing plan
Unit
Ajouter un test dans crates/rustwarden-core/src/middleware/auth_service.rs#tests :
#[tokio::test] async fn optional_auth_with_missing_extension_falls_back_to_env() { // Setup: env JWT_SECRET valide, mais pas d'Extension(AuthConfig) dans la request std::env::set_var("JWT_SECRET", "0".repeat(64)); let req = test_request_with_jwt_cookie_only(/* valid token */); let response = AuthService::call(req).await; // Le claims doit être présent dans response.extensions() assert!(response.extensions().get::<Claims>().is_some()); } #[tokio::test] async fn optional_auth_with_extension_uses_it_preferentially() { let custom_config = Arc::new(AuthConfig { /* non-default values */ }); let req = test_request_with_jwt_cookie_and_ext(token, custom_config.clone()); let response = AuthService::call(req).await; // Le claims doit être présent ET validé avec custom_config (pas from_env) assert!(response.extensions().get::<Claims>().is_some()); } #[tokio::test] async fn optional_auth_with_no_jwt_passes_through() { let req = test_request_without_jwt(); let response = AuthService::call(req).await; // claims absent — comportement inchangé pour les visiteurs non authentifiés assert!(response.extensions().get::<Claims>().is_none()); }
Integration
Test avec un router minimal :
- Route
GET /test(non-protected, handler avecOption<Extension<Claims>>) - Layer ordering reproductible (Extension(AuthConfig) après AuthService)
- Login via POST /login pour générer un cookie
- Vérifier que
GET /testrenvoie 200 avec claims présent
Regression
Vérifier que tous les tests existants passent (le fallback ne touche pas la branche protected déjà fonctionnelle).
Compat
Le crate gitrust (consommateur de référence) doit pouvoir retirer ses register_protected_prefix("/dashboard") après bump et que le login → /dashboard fonctionne sans plus. À documenter dans le CHANGELOG.
Migration guide
À copier dans le CHANGELOG.md de rustwarden-core au merge :
Fixed (0.X+1)
- Middleware
AuthService: la branche optional auth (routes horsprotected_prefixes) applique désormais le fallbackAuthConfig::from_env()quand l'extensionExtension(Arc<AuthConfig>)n'est pas encore posée par les layers downstream. Symétrise le comportement avec la branche protected, déjà patchée pard4d6828.
Conséquence : les consommateurs qui avaient ajouté des register_protected_prefix() uniquement pour contourner ce bug peuvent les retirer s'ils n'ont pas d'autre raison de protéger ces routes (par exemple gitrust : /dashboard, /teams, /notifications peuvent rester sans protection si désiré, le claims sera quand même injecté).
Migration steps
Aucune action obligatoire. Recommandation :
- Bump
rustwarden-coreà la version contenant le fix. - Auditer ses
register_protected_prefix(): ceux ajoutés uniquement pour contourner ce bug peuvent être retirés. - Garder ceux qui sont là pour des raisons sécuritaires (rejeter les non-authentifiés sur ces prefixes —
optional_authne fait pas ce rejet).
Open questions
-
Faut-il aussi logger un warning quand on tombe dans le fallback (
Extension absent, fallback to from_env) ? Pourrait alerter les déployeurs d'une mauvaise config ordre des layers. À discuter — peut-être debug level pour ne pas spammer. -
Pour éviter de retomber dans le cas A (alternative B), ajouter à la doc
app.rsun avertissement sur l'ordre des layers nécessaires. Optionnel.
Score sur la grille du processus §4
| Critère | Note | Justification |
|---|---|---|
| Généralité | 5 | Tout consommateur qui mappe des routes auth-required hors protected_prefixes bénéficie |
| Stabilité API | 5 | Aucun changement d'API publique |
| Performance | 5 | Identique en happy path (extension présente) ; +1 env parse en fallback (négligeable) |
| Sécurité | 4 | Aucune régression : le fallback utilise toujours un AuthConfig valide (sinon le serveur n'aurait pas démarré au lot 1 grâce à F-NET-5) |
| Complexité impl | 5 | ~12 lignes de Rust, pattern existant |
| Maintenance | 5 | Zéro nouvelle dépendance |
| Documentation | 5 | Rustdoc + commentaire inline + entrée CHANGELOG explicite |
| Total | 34/35 | ≥ 30 = accélération possible |
Tracking issue
À ouvrir sur le repo rustwarden-core avec labels bug + area:auth + priority:medium. Mentionner gitrust comme consommateur impacté avec reproducer en CLI fourni dans §Motivation.
Historique :
- 2026-05-16 : Draft initial — bug identifié pendant l'intégration fediverse côté gitrust. Reproducer minimal fourni. Workaround
register_protected_prefix()appliqué côté gitrust en attendant le fix. - 2026-05-16 : Accepted + implémenté (Alternative A). Patch dans
crates/rustwarden-core/src/middleware/auth_service.rs(branche optional, ~14 lignes). Test de non-régression étendu danscrates/rustwarden-core/tests/middleware_authconfig_fallback.rs(en-tête mise à jour + nouveauauth_config_from_env_yields_signature_consistent_loader). Entrée### Fixedajoutée au CHANGELOG. Plan d'exécution archivé dansdocs/plans/2026-05-16-auth-service-optional-fallback.md.
GitRust