0031-auth-service-optional-fallback.md 17816 octets

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_prefixes par défaut du framework.
  • Crate cible : rustwarden-core (correction d'un bug interne au middleware AuthService)
  • 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 :

  1. Active experimental-fediverse ou tout autre crate qui mount des routes auth-required en utilisant Option<Extension<Claims>> côté handler.
  2. Expose des routes auth-required hors des protected_prefixes par 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, types Claims — 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::new directement 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_prefix toute 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 avec Option<Extension<Claims>>)
  • Layer ordering reproductible (Extension(AuthConfig) après AuthService)
  • Login via POST /login pour générer un cookie
  • Vérifier que GET /test renvoie 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 hors protected_prefixes) applique désormais le fallback AuthConfig::from_env() quand l'extension Extension(Arc<AuthConfig>) n'est pas encore posée par les layers downstream. Symétrise le comportement avec la branche protected, déjà patchée par d4d6828.

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 :

  1. Bump rustwarden-core à la version contenant le fix.
  2. Auditer ses register_protected_prefix() : ceux ajoutés uniquement pour contourner ce bug peuvent être retirés.
  3. Garder ceux qui sont là pour des raisons sécuritaires (rejeter les non-authentifiés sur ces prefixes — optional_auth ne 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.rs un avertissement sur l'ordre des layers nécessaires. Optionnel.

Score sur la grille du processus §4

CritèreNoteJustification
Généralité5Tout consommateur qui mappe des routes auth-required hors protected_prefixes bénéficie
Stabilité API5Aucun changement d'API publique
Performance5Identique en happy path (extension présente) ; +1 env parse en fallback (négligeable)
Sécurité4Aucune 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é impl5~12 lignes de Rust, pattern existant
Maintenance5Zéro nouvelle dépendance
Documentation5Rustdoc + commentaire inline + entrée CHANGELOG explicite
Total34/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 :