0042-auth-middleware-resolved-auth-config.md 44833 octets

RFC 0042 — Auth — AuthService / AdminService reçoivent l'AuthConfig résolue à la construction

  • Auteur : EBO (origine BotPlace — docs/UPSTREAM_RUSTWARDEN.md, § « Workarounds / besoins upstream », 2026-10-02)
  • Date : 2026-10-02
  • Statut : Implemented (2026-10-02, train 1.2.0) — D1 tranchée par le mainteneur en faveur de l'Alternative A ; rupture de défaut assumée, consommateurs adaptés manuellement au déploiement. Plan d'exécution local (dossier docs/plans/, ignoré par git) : 2026-10-02-rfc-0042-implementation.md.
  • Apps consommatrices concernées : BotPlace (bug reproduit par crates/botplace-ui/tests/prefixes_proteges.rs) ; tout projet généré par rustwarden-cli start-project en mode headless ; app/examples/minimal_headless.rs ; gitrust et rs_pasterman dès qu'ils utilisent .login_path(..) ou .auth_failure_mode(..).
  • Crates cibles : rustwarden-core (middleware/auth_service.rs, middleware/admin_service.rs, app.rs) ; rustwarden-ui (handlers/root_handler.rs, correctif compagnon de trois lignes).
  • Type : correctif structurel + dépréciation (API additive, aucune suppression avant la prochaine major) + un changement de défaut effectif à arbitrer (D1).
  • Précédents : RFC 0022 (fallback from_env() branche protected), RFC 0031 (fallback branche optional). La présente RFC reprend l'idée de l'Alternative C de la RFC 0031 (L248-257, écartée alors parce qu'elle modifiait la signature de new) en la rendant additive par un second constructeur.

Summary

Donner à AuthService et AdminService l'Arc<AuthConfig> résolue une fois par RustwardenBuilder::build() au lieu de la rechercher à chaque requête dans les extensions puis de retomber sur AuthConfig::from_env(), afin que .login_path(), .auth_failure_mode() et la bascule .headless() soient enfin honorés par les middlewares de préfixe.

Motivation

Constat (BotPlace, 2026-10-02 — vérifié sur main 5df0405)

Dans app.rs (L788-811), le ServiceBuilder empile, du plus externe au plus interne (le premier .layer() est le plus externe) :

cors → security_headers → AdminService → AuthService → session → locale
     → Extension(i18n) → Extension(hooks) → Extension(auth_config) → Extension(db) → Extension(analytics) → router

Trois Router::layer successifs (L820, L828, L831) posent ensuite Extension(revocation_checker), Extension(dashboard_cards) et Extension(oauth_registry) encore plus à l'extérieur que cors — ce qui est l'idiome du dépôt quand une extension doit être visible d'AuthService (commentaire L815-818 : « so AuthService can consult it »). Extension(auth_config) n'a pas reçu ce traitement.

AdminService et AuthService lisent parts.extensions.get::<Arc<AuthConfig>>() (auth_service.rs L239-258 branche protected, L470-484 branche optional ; admin_service.rs L72-90). À cet instant l'extension n'est pas encore posée : ils retombent systématiquement sur AuthConfig::from_env().

Or build() (L513-526) applique les overrides du builder sur sa copie locale avant de la poser en Extension :

let mut auth_config = AuthConfig::from_env()?;
if let Some(path) = self.login_path.take() { auth_config.login_path = path; }
if let Some(mode) = self.auth_failure_mode { auth_config.auth_failure_mode = mode; }
else if self.headless { auth_config.auth_failure_mode = AuthFailureMode::Json401; }

Cette copie n'est jamais vue par les deux middlewares.

Les huit sites AuthConfig::from_env() en chemin de requête

#SiteVoit l'extension dans le câblage canonique ?
1auth_service.rs L245 (branche protected)Non → from_env() à chaque requête
2auth_service.rs L475 (branche optional)Non → from_env() à chaque requête authentifiée
3admin_service.rs L78Non → from_env() à chaque requête /admin*
4rustwarden-ui/src/handlers/root_handler.rs L48 (GET / avec cookie JWT)N'essaie même pas : from_env() direct
5auth_service.rs L71 (auth_failure_from_extensions)Oui (appelé depuis les extracteurs)
6middleware/admin_helper.rs L59Oui
7auth/extractors.rs L200 (AuthUser)Oui
8middleware/auth_required.rs L47Oui

Les sites 1 à 4 sont dans le périmètre de cette RFC. Les sites 5 à 8 fonctionnent correctement aujourd'hui (ils s'exécutent après le layer Extension) et restent hors périmètre (voir Non-goals).

Quatre conséquences observables

  1. Les setters publics du builder sont inopérants sur les deux points d'application principaux. .login_path("/auth/signin") et .auth_failure_mode(AuthFailureMode::Redirect) sont documentés, acceptés, puis ignorés par AuthService (préfixes protégés) et AdminService (/admin*, /api/v1/admin*). Le contrat réel devient LOGIN_PATH / AUTH_FAILURE_MODE dans l'environnement — exactement le workaround que BotPlace a dû documenter dans son .env.example.

  2. La promesse de .headless() n'est pas tenue. La rustdoc de headless() (app.rs L370-376) et docs/SERVICES_REFERENCE.md (L1335) annoncent : « bascule le défaut sur Json401 ». En pratique, un projet headless qui enregistre register_protected_prefix("/api/v1/private") répond 303 See Other → /login?redirect=… vers une page qui n'existe pas, sauf si AUTH_FAILURE_MODE=json401 est posé dans l'environnement. /api/v1/admin/* (couvert par AdminService) a le même défaut.

  3. Comportement incohérent dans une même application. Les sites 5 à 8 honorent le builder ; les sites 1 à 3 ne l'honorent pas. Dans la même app headless : /whoami (extracteur AuthUser, câblé par le scaffold CLI) → 401 JSON ; /api/v1/private (préfixe protégé) → 303 → /login.

  4. Coût et bruit sur le hot path. from_env() est rejoué à chaque requête sur préfixe protégé, à chaque requête /admin*, à chaque requête hors préfixe portant un JWT non blacklisté (auth_service.rs L452-463), et sur GET / avec cookie pour les apps with_ui : seize lectures d'environnement, allocations, validation du secret, et un tracing::info!("Auth configuration loaded…") (config/auth.rs L224-229) par requête. Le chemin « 500 si from_env() échoue » reste atteignable à chaud si l'environnement du processus est modifié après le démarrage — la configuration validée au boot peut dériver silencieusement.

Pourquoi les tests ne l'attrapent pas

  • tests/middleware_authconfig_fallback.rs teste from_env() lui-même (idempotence, roundtrip de signature), pas la propagation des overrides.
  • Les tests du builder (app.rs L1098 headless_sets_flag_without_ui_prefixes) vérifient le flag, pas le comportement HTTP résultant.
  • Aucun test n'instancie AuthService avec un AuthConfig explicite : c'est impossible sans muter l'environnement du processus (unsafe { set_var } en édition 2024), ce qui sérialise les tests et interdit la parallélisation.

Historique

Les RFC 0022 puis 0031 ont ajouté puis symétrisé le fallback au lieu de traiter la cause (résolution par extension dépendante de l'ordre des layers). La RFC 0031 listait l'injection par constructeur et l'écartait faute de besoin consommateur et par crainte d'un breaking sur new. BotPlace fournit le besoin ; un second constructeur plus une dépréciation permettent de livrer la partie « injection » en minor.

Conséquences si on ne fait rien

Chaque consommateur headless ou à UI SSR propre redécouvre le piège (gitrust 2026-05-16, BotPlace 2026-10-02). Deux setters publics restent une API documentée mais sans effet sur les middlewares de préfixe. docs/auth-middleware-layering.md présente comme « fallback » ce qui est le chemin nominal.

Urgence : modérée. Le workaround par variables d'environnement existe. Mais l'écart entre l'API publique du builder et le comportement observé entame la crédibilité du framework auprès de ses consommateurs.

Non-goals

  • Ne pas changer la sémantique des deux branches d'AuthService (protected / optional), ni la liste protected_prefixes, ni register_protected_prefix.
  • Ne pas retirer les fallbacks from_env() des sites 5 à 8 (auth_failure_from_extensions, admin_helper, AuthUser, auth_required) : ils fonctionnent aujourd'hui et deviennent de simples défenses ; leur suppression est planifiée avec celle des constructeurs dépréciés (prochaine major).
  • Ne pas supprimer le layer Extension(Arc<AuthConfig>) d'app.rs : il reste la source des handlers ; redondance acceptée (voir OQ3).
  • Ne pas toucher à AuthConfig::from_env() ni au parsing de AUTH_FAILURE_MODE / LOGIN_PATH. En particulier, ne pas ajouter de champ à AuthConfig : la struct (config/auth.rs L37-75) a tous ses champs pub et n'est pas #[non_exhaustive], un champ supplémentaire casserait toute construction par littéral (dont le helper de test auth_service.rs L514-520).
  • Ne pas supporter l'override par requête de la configuration via Extension(Arc<AuthConfig>) sur les middlewares de préfixe : la configuration capturée à la construction est autoritaire (aucun site du workspace ne pose une telle extension par requête ; le seul producteur est app.rs L809, au démarrage).
  • Ne pas introduire de tower::Layer dédiés (voir OQ4).
  • Pas de changement de schéma DB, pas de changement de format sérialisé.

API proposée

AuthService

// crates/rustwarden-core/src/middleware/auth_service.rs
#[derive(Clone)]
pub struct AuthService<S> {
    inner: S,
    db: DatabaseConnection,
    protected_prefixes: Arc<Vec<String>>,
    /// Configuration resolved once by `RustwardenBuilder::build()`.
    /// `None` only on the deprecated `AuthService::new` path, which keeps
    /// the historical lookup (request extension, then `AuthConfig::from_env`).
    auth_config: Option<Arc<AuthConfig>>,
}

impl<S> AuthService<S> {
    /// Create the middleware with the authoritative [`AuthConfig`].
    ///
    /// Used by [`crate::app::RustwardenBuilder::build`] since 1.2.0. The
    /// config is captured once at construction (same pattern as
    /// `security_headers_middleware`), so builder overrides such as
    /// [`crate::app::RustwardenBuilder::login_path`] and
    /// [`crate::app::RustwardenBuilder::auth_failure_mode`] are honoured
    /// regardless of where `Extension(Arc<AuthConfig>)` sits in the stack.
    ///
    /// The captured config takes precedence over any
    /// `Extension(Arc<AuthConfig>)` found in the request. On every request
    /// the middleware also inserts it into the request extensions, so
    /// downstream extractors (`AuthUser`, `auth_required`, `admin_required`)
    /// observe the very same instance even in hand-composed tower stacks.
    ///
    /// ```no_run
    /// # use std::sync::Arc;
    /// # use rustwarden_core::config::auth::AuthConfig;
    /// # use rustwarden_core::middleware::auth_service::AuthService;
    /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
    /// let auth_config = Arc::new(AuthConfig::from_env()?);
    /// let prefixes = Arc::new(vec!["/api/private".to_string()]);
    /// let app = router.layer(tower::layer::layer_fn(move |inner| {
    ///     AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone())
    /// }));
    /// # let _ = app; Ok(()) }
    /// ```
    pub fn with_auth_config(
        inner: S,
        db: DatabaseConnection,
        protected_prefixes: Arc<Vec<String>>,
        auth_config: Arc<AuthConfig>,
    ) -> Self {
        Self { inner, db, protected_prefixes, auth_config: Some(auth_config) }
    }

    /// Legacy constructor: resolves `AuthConfig` per request from the
    /// extensions, then from the environment.
    #[deprecated(
        since = "1.2.0",
        note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; \
                the per-request `AuthConfig::from_env()` fallback ignores builder \
                overrides (`login_path`, `auth_failure_mode`, `headless`). \
                Removal no earlier than 1.5.0, effective in the next major."
    )]
    pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self {
        Self { inner, db, protected_prefixes, auth_config: None }
    }
}

/// Authoritative config first; request extension then environment only on
/// the deprecated path. A free function so it can be called from inside the
/// `'static` boxed future returned by `call` (where `self` is unavailable).
fn resolve_auth_config(
    captured: Option<&Arc<AuthConfig>>,
    extensions: &axum::http::Extensions,
) -> Result<Arc<AuthConfig>, String> {
    if let Some(cfg) = captured {
        return Ok(cfg.clone());
    }
    if let Some(cfg) = extensions.get::<Arc<AuthConfig>>() {
        return Ok(cfg.clone());
    }
    AuthConfig::from_env().map(Arc::new)
}

Dans call (signature à passer en fn call(&mut self, mut req: Request<B>)), avant le Box::pin :

// Seed the request so every downstream extractor sees the same config,
// whatever the consumer's layer ordering. In the canonical wiring the
// `Extension(Arc<AuthConfig>)` layer later re-inserts the same `Arc`
// (a no-op); the seeding only matters for hand-composed stacks.
if let Some(cfg) = &self.auth_config {
    req.extensions_mut().insert(cfg.clone());
}
// Cloned here because the boxed future below cannot borrow `self`.
let captured = self.auth_config.clone();

Les deux branches remplacent leur bloc match parts.extensions.get… / None => from_env() par resolve_auth_config(captured.as_ref(), &parts.extensions). Le traitement d'erreur (500 + tracing::error! sur protected, traversée sans claims sur optional) est conservé pour le chemin déprécié.

AdminService

// crates/rustwarden-core/src/middleware/admin_service.rs
#[derive(Clone)]
pub struct AdminService<S> {
    inner: S,
    db: DatabaseConnection,
    auth_config: Option<Arc<AuthConfig>>,
}

impl<S> AdminService<S> {
    /// Create the middleware with the authoritative [`AuthConfig`]
    /// (see `AuthService::with_auth_config` for the rationale).
    ///
    /// ```no_run
    /// # use std::sync::Arc;
    /// # use rustwarden_core::config::auth::AuthConfig;
    /// # use rustwarden_core::middleware::admin_service::AdminService;
    /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
    /// let auth_config = Arc::new(AuthConfig::from_env()?);
    /// let app = router.layer(tower::layer::layer_fn(move |inner| {
    ///     AdminService::with_auth_config(inner, db.clone(), auth_config.clone())
    /// }));
    /// # let _ = app; Ok(()) }
    /// ```
    pub fn with_auth_config(inner: S, db: DatabaseConnection, auth_config: Arc<AuthConfig>) -> Self {
        Self { inner, db, auth_config: Some(auth_config) }
    }

    #[deprecated(
        since = "1.2.0",
        note = "use `AdminService::with_auth_config(inner, db, auth_config)`. \
                Removal no earlier than 1.5.0, effective in the next major."
    )]
    pub fn new(inner: S, db: DatabaseConnection) -> Self {
        Self { inner, db, auth_config: None }
    }
}

Même fonction libre resolve_auth_config (partagée via pub(crate) depuis auth_service.rs), même seeding, même mut req.

RustwardenBuilder::build() (app.rs)

// 3. Auth config & session
let failure_mode_set_in_env = std::env::var_os("AUTH_FAILURE_MODE").is_some();
let auth_config = self.apply_auth_overrides(
    AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?,
    failure_mode_set_in_env,
);
// Must stay AFTER `apply_auth_overrides`: the session layer reads
// `session_timeout_minutes` / cookie settings from the final config.
let session_layer = SessionConfig::create_memory_layer(&auth_config);
let auth_config = Arc::new(auth_config);
let cfg_for_admin = auth_config.clone();
let cfg_for_auth = auth_config.clone();
// ...
ServiceBuilder::new()
    .layer(cors_layer)
    .layer(/* security_headers_middleware, unchanged */)
    .layer_fn(move |service| {
        AdminService::with_auth_config(service, db_for_admin.clone(), cfg_for_admin.clone())
    })
    .layer_fn(move |service| {
        AuthService::with_auth_config(
            service,
            db_for_auth.clone(),
            prefixes_for_auth.clone(),
            cfg_for_auth.clone(),
        )
    })
    .layer(session_layer)
    // ...
    .layer(axum::Extension(auth_config)) // kept on purpose, see OQ3

Extraction d'une fonction pure (aucune lecture d'environnement ; le signal env est passé en paramètre) pour la précédence des overrides :

impl RustwardenBuilder {
    /// Apply builder overrides on top of the environment-loaded config.
    ///
    /// Precedence (D1): explicit setter > `AUTH_FAILURE_MODE` in the
    /// environment > `headless()` default flip > `Redirect`.
    fn apply_auth_overrides(&self, mut cfg: AuthConfig, failure_mode_set_in_env: bool) -> AuthConfig {
        if let Some(path) = &self.login_path {
            cfg.login_path = path.clone();
        }
        if let Some(mode) = self.auth_failure_mode {
            cfg.auth_failure_mode = mode;
        } else if self.headless && !failure_mode_set_in_env {
            cfg.auth_failure_mode = AuthFailureMode::Json401;
        }
        cfg
    }
}

Correctif compagnon — rustwarden-ui/src/handlers/root_handler.rs (site 4)

// Prefer the config resolved at build time; keep `from_env()` as a defence
// for stacks that do not pose the extension.
let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() {
    Some(cfg) => cfg,
    None => match AuthConfig::from_env() {
        Ok(cfg) => Arc::new(cfg),
        Err(_) => { /* unchanged: warn + redirect to login */ }
    },
};

Trois lignes, aucune API touchée. Nécessaire pour que les énoncés de performance et d'observabilité ci-dessous soient vrais aussi pour les apps with_ui.

Exemple consommateur : pile tower composée à la main (hors builder)

let auth_config = Arc::new(AuthConfig::from_env()?);
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(),
);
// No need to add Extension(Arc<AuthConfig>) yourself: the middleware seeds it.

À livrer tel quel dans app/examples/custom_auth_stack.rs (processus §8, item 5 : exemple obligatoire pour un pattern middleware).

Reproduction (avant / après)

Diff réel sur app/examples/minimal_headless.rs :

     RustwardenApp::builder()
         .from_env()
         .headless()
         .auto_migrate(true)
+        .register_protected_prefix("/api/status")
         .merge_machine_routes(api_routes())
unset AUTH_FAILURE_MODE
cargo run -p rustwarden-app --example minimal_headless &
curl -si http://localhost:3000/api/status | head -2
# Avant :  HTTP/1.1 303 See Other          ← attendu 401 en headless
#          location: /login?redirect=%2Fapi%2Fstatus
# Après :  HTTP/1.1 401 Unauthorized
#          {"error":"unauthorized"}

Analyse de compatibilité

  • API existante étendue (with_auth_config sur les deux middlewares, apply_auth_overrides privé) → non-breaking, minor.
  • API existante dépréciée, non supprimée → #[deprecated] avec note et remplacement. Processus §5 : suppression possible au plus tôt trois minors après la dépréciation (1.2.0 → 1.5.0), effective dans la première major ultérieure ; entrée Deprecated à reporter dans chaque minor intermédiaire. Appelants de new dans le workspace au 2026-10-02 : app.rs L797 et L799 uniquement ; les templates rustwarden-cli n'appellent pas les constructeurs ; aucun appel dans rustwarden-ui, rustwarden-fediverse*, app/.
  • API existante modifiée ou supprimée → non. Mais voir la ligne suivante.
  • Nouveau comportement par défaut effectif → oui, et c'est le point à arbitrer (D1). Le processus §1 (principe 1) qualifie « une modification du comportement par défaut » de breaking « même si le code compile », §5 interdit tout breaking en minor, et §6 tranche l'ambiguïté en faveur de « stable » (les codes HTTP du happy path sont listés comme stables). Population exposée : apps .headless() sans .auth_failure_mode(..) ni AUTH_FAILURE_MODE posé → leurs préfixes protégés et /api/v1/admin* passent de 303 → /login (bug) à 401 JSON (défaut documenté depuis 05aeaca, SERVICES_REFERENCE.md L1335). Pour les apps with_ui(..) : rien ne change.
    • Recensement des consommateurs connus : BotPlace est .headless() avec UI SSR propre, mais appelle .auth_failure_mode(AuthFailureMode::Redirect) sur le builder et pose AUTH_FAILURE_MODE=redirect (docs/UPSTREAM_RUSTWARDEN.md, § A2 et § Workarounds) → non affectée, quelle que soit la précédence retenue. gitrust et rs_pasterman sont des apps with_ui → non affectées. Les projets scaffoldés par rustwarden-cli en headless n'enregistrent aucun préfixe protégé par défaut et protègent whoami par l'extracteur AuthUser (déjà Json401) → seul /api/v1/admin* change de code, dans le sens attendu par un client API. Population résiduelle : apps headless + SSR qui s'appuient sur le 303 sans setter ni env — aucune connue ; remédiation sans redéploiement de code : AUTH_FAILURE_MODE=redirect.
    • Précédence retenue (ex-OQ1) : setter > env > bascule headless > Redirect. headless() documente une bascule de défaut ; une valeur d'environnement explicite n'est pas un défaut. C'est aussi ce qui réduit la population exposée au strict minimum ci-dessus.
    • D1 — à trancher par le mainteneur : (a) livrer la bascule en 1.2.0 comme restauration d'un défaut documenté, avec entrée CHANGELOG Changed, rollback par variable d'environnement et patch 1.2.1 de secours (Alternative A, recommandée) ; ou (b) livrer l'injection en 1.2.0 mais garder la bascule headless derrière un opt-in jusqu'à la prochaine major (Alternative E, strictement non-breaking au sens §1).
  • Projets headless déjà générés par le CLI et déployés : passent par update-project / .rustwarden-version. À vérifier chez eux avant adoption : grep -rn register_protected_prefix src/ et clients de /api/v1/admin* qui attendraient un 303.
  • Extracteurs internes (sites 5 à 8) : inchangés ; ils voient la même instance qu'avant.
  • Changement de schéma DB → non.
  • Changement de format sérialisé → non.
  • Logs : disparition du Auth configuration loaded par requête sur les sites 1 à 4 (format des logs = non-stable, §6).
  • Versions : les sept manifestes Cargo.toml du workspace sont à 1.0.0 alors que le CHANGELOG a publié 1.0.1 et 1.1.0 (seuls tags git : V0.1.0, v01). La numérotation de cette RFC (1.2.0, hotfix 1.1.1) suit le CHANGELOG ; à réaligner au moment du train.

Alternatives considérées

Alternative A (recommandée) : injection par constructeur + seeding + dépréciation + bascule headless effective

Décrite ci-dessus, avec la précédence setter > env > headless.

Pros : supprime structurellement la dépendance à l'ordre des layers pour les sites 1 à 3 ; une seule AuthConfig validée au boot pour tous les points d'application ; retire from_env() du hot path (sites 1 à 4) ; rend AuthService / AdminService testables sans environnement ni base ; pattern déjà adopté par security_headers_middleware (RFC 0039) ; corrige aussi les piles composées à la main ; la promesse de headless() devient vraie partout.

Cons : deux constructeurs à maintenir jusqu'à la major ; ~90 lignes sur quatre fichiers ; un défaut effectif change pour une population résiduelle (D1) ; deux sources de la même instance dans la requête (seeding + layer Extension), inertes l'une par rapport à l'autre dans le câblage canonique.

Alternative B : remonter Extension(auth_config) en tête de pile

Poser .layer(axum::Extension(auth_config.clone())) en Router::layer après le ServiceBuilder, comme revocation_checker (app.rs L815-820).

Pros : trois lignes, aucun changement d'API, livrable en patch 1.1.1 ; débloque BotPlace immédiatement ; c'est l'idiome déjà employé dans le dépôt pour rendre une extension visible d'AuthService.

Cons : la correction dépend toujours de l'ordre des layers (le bug reviendra au prochain réagencement, comme après d4d6828) ; les piles composées à la main par les consommateurs restent cassées ; le fallback from_env() par requête subsiste en code mort défensif ; les tests exigent toujours de muter l'environnement. Écartée par la RFC 0031 pour les mêmes raisons. Compatible avec A comme mesure transitoire (OQ2).

Alternative C : tower::Layer dédiés (AuthLayer, AdminLayer)

Pros : plus idiomatique ; les consommateurs réutilisent le layer sans closure.

Cons : surface d'API supplémentaire à stabiliser ; ne corrige rien de plus que A ; peut être ajouté par-dessus A ultérieurement (OQ4).

Alternative D : ne rien faire (documenter que le contrat réel est l'environnement)

Pros : zéro coût immédiat.

Cons : deux setters publics restent une API sans effet ; la rustdoc de headless() et SERVICES_REFERENCE.md sont fausses ; chaque consommateur headless redécouvre le piège ; coût et log par requête conservés ; impossibilité de tester les overrides sans unsafe { set_var }.

Alternative E : injection en 1.2.0, bascule headless différée derrière un opt-in

Identique à A pour les constructeurs, le seeding et la dépréciation. Mais apply_auth_overrides ne bascule Json401 sur headless() que si le consommateur appelle un nouveau setter .headless_json401(true) (ou active une feature experimental-headless-json401, processus §5 « feature flags » : naissance en minor, promotion après deux apps adoptantes et 30 jours, défaut en major).

Pros : strictement non-breaking au sens §1 ; les setters explicites .login_path() / .auth_failure_mode() sont honorés dès 1.2.0 (c'est le besoin BotPlace) ; la bascule arrive en major avec son guide MIGRATION-V2.md.

Cons : prolonge volontairement pendant tout un cycle l'incohérence entre AuthUser (déjà Json401 en headless) et les middlewares de préfixe ; ajoute un setter ou une feature temporaire à retirer ensuite ; la rustdoc de headless() reste fausse jusqu'à la major sauf à la réécrire deux fois.

Choix proposé : Alternative A, avec B acceptée comme hotfix optionnel (OQ2) et E comme repli si le mainteneur tranche D1 en faveur d'une lecture stricte de §1.

Testing plan

Les tests unitaires ci-dessous tournent sans PostgreSQL (MockDatabase via la dev-dépendance sea-orm/mock déjà déclarée) et n'exigent aucune variable d'environnement. Attention : ils ne peuvent pas garantir l'absence de JWT_SECRET, car plusieurs mod tests du même binaire de lib appellent dotenvy::dotenv().ok() (hooks.rs L381, tasks/pending_email_change_cleanup.rs L100, …) et chargent le .env du dépôt dans le processus. La preuve que with_auth_config ne touche plus from_env() est donc portée par le test #6 ci-dessous, dans un binaire d'intégration dédié. Pattern HTTP : Router + tower::ServiceExt::oneshot, comme les tests de security_headers_middleware dans app.rs.

Mock de la blacklist

JwtBlacklistService::is_blacklisted (services/jwt_blacklist_service.rs L147-162) exécute un count(). Sur la branche protected, Err(_) est traité comme « blacklisté » (auth_service.rs L272-278) : un MockDatabase sans résultat court-circuite donc avant validate_token. Les tests #4 et #5 doivent appendre un résultat de comptage (aucun précédent dans le dépôt, qui ne mocke que des Model) :

use sea_orm::{DatabaseBackend, MockDatabase, Value};
use std::collections::BTreeMap;

fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> {
    BTreeMap::from([("num_items", Value::BigInt(Some(n)))])
}
let db = MockDatabase::new(DatabaseBackend::Postgres)
    .append_query_results([vec![blacklist_count(0)]])
    .into_connection();

Unitaires — auth_service.rs

  1. Happy : préfixe protégé, pas de JWT, Json401 injecté → 401 + corps {"error":"unauthorized"} (aucune requête DB émise).
  2. Happy : Redirect + login_path = "/auth/signin" injectés → 303 + Location: /auth/signin?redirect=%2Fprivate.
  3. Seeding : route hors préfixe, handler lisant Extension<Arc<AuthConfig>>, aucun layer Extension dans la pile de test → le login_path renvoyé est celui injecté.
  4. Erreur — signature invalide : JWT signé avec un autre secret, blacklist mockée à 0 (le flux atteint validate_token), Json401 → 401. Assertion complémentaire : le journal du mock (into_transaction_log()) contient exactement une requête COUNT.
  5. Erreur — blacklist : JWT valide (signé avec le secret injecté), blacklist mockée à 1 → 401. Le token valide distingue ce test du #4 : il prouve que le court-circuit blacklist s'applique à un token qui aurait autrement passé.
  6. Legacy / preuve d'indépendance à l'environnement — dans crates/rustwarden-core/tests/auth_service_resolved_config.rs (binaire isolé, pattern ENV_LOCK + remove_var("JWT_SECRET") de tests/middleware_authconfig_fallback.rs L19-46) : avec JWT_SECRET retiré, AuthService::with_auth_config répond 401 sur un préfixe protégé là où AuthService::new (#[allow(deprecated)]) répond 500 ; avec Extension posée en amont, new l'utilise (comportement historique conservé).

Unitaires — admin_service.rs

  1. /admin et /api/v1/admin/x sans JWT, Json401 → 401 ; Redirect + login_path custom → 303 vers ce chemin.

Unitaires — app.rs

  1. apply_auth_overrides (fonction pure, parallélisable) : headless() → Json401 ; setter Redirect prime sur headless() ; failure_mode_set_in_env = true prime sur headless() ; login_path propagé ; non-headless sans setter → inchangé.

Unitaires — rustwarden-ui

  1. root_redirect avec Extension(Arc<AuthConfig>) posée et cookie JWT valide → redirige vers /home sans appel à from_env() (vérifiable par un login_path non standard dans la config injectée, impossible à obtenir depuis l'environnement du test).

Intégration

  1. tests/middleware_authconfig_fallback.rs conservé tel quel (il couvre from_env() et le chemin déprécié).
  2. Bout en bout, si un harnais DB est disponible en CI : RustwardenBuilder::default().headless().register_protected_prefix("/p").build() + oneshot → 401 ; et test de garde sur l'ordre des layers : une route dont le handler lit Extension<Arc<AuthConfig>> renvoie la config du builder (protège les sites 5 à 8, toujours sensibles à l'ordre). Sinon, couverture consommateur : prefixes_proteges.rs (BotPlace), à relancer sans AUTH_FAILURE_MODE.

Non-régression

  1. cargo build --workspace --examples sans warning (aucun appel à new ne subsiste hors tests).
  2. cargo clippy --workspace zéro warning ; cargo doc sans warning (vérifier en particulier les liens intra-doc vers les items dépréciés).
  3. Projet généré par rustwarden-cli start-project en headless : compile et répond 401 sur /api/v1/admin/users sans token.

Doctests

  1. Les exemples no_run de AuthService::with_auth_config et AdminService::with_auth_config compilent.

Benchs (processus §7 : obligatoire, hot path touché)

  1. Micro-mesure AuthConfig::from_env() seule (std::time::Instant, 10 000 itérations) et oneshot sur préfixe protégé sans JWT, pile legacy vs pile with_auth_config, même machine. Livré : test #[ignore] bench_from_env_versus_captured_config dans tests/auth_service_resolved_config.rs (cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench). Résultat (debug, 2026-10-02) : from_env() 23,7 ms / 10 000 = 2,37 µs par appel ; pile legacy 40,2 µs/req ; pile with_auth_config 37,7 µs/req (−6 %). Aucune dépendance de bench ajoutée.

Observabilité

  1. Auth configuration loaded n'apparaît qu'une fois au démarrage sur une app with_ui et sur une app headless (sites 1 à 4 couverts).

Migration guide

Section à copier dans CHANGELOG.md au merge ([1.2.0]).

### Fixed

- **RFC 0042.** `AuthService` et `AdminService` honorent désormais
  `RustwardenBuilder::login_path`, `::auth_failure_mode` et la bascule
  `Json401` de `::headless()`. Les deux middlewares retombaient
  systématiquement sur `AuthConfig::from_env()` (ordre des layers) et ne
  lisaient que `LOGIN_PATH` / `AUTH_FAILURE_MODE`. Préfixes protégés,
  `/admin*` et extracteurs `AuthUser` / `auth_required` partagent enfin la
  même configuration. `rustwarden-ui::root_redirect` lit aussi la
  configuration injectée avant de retomber sur l'environnement.

### Changed

- Apps `.headless()` sans `.auth_failure_mode(..)` ni `AUTH_FAILURE_MODE` :
  les préfixes enregistrés via `register_protected_prefix` et `/api/v1/admin*`
  répondent `401 {"error":"unauthorized"}` au lieu de `303 → /login`
  (comportement documenté, jusqu'ici non appliqué). Si votre app headless
  sert une UI SSR, appelez `.auth_failure_mode(AuthFailureMode::Redirect)`
  ou posez `AUTH_FAILURE_MODE=redirect` (désormais prioritaire sur la
  bascule `headless`).
- `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les
  middlewares de préfixe ni par `root_redirect` ; dans le câblage canonique,
  le log `Auth configuration loaded` n'apparaît plus qu'au démarrage.

### Deprecated

- `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`.
- `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`.
  Suppression au plus tôt en 1.5.0, effective dans la prochaine major.

Migration steps

  1. Bump rustwarden-core à 1.2.0 (et réaligner les manifestes du workspace, aujourd'hui à 1.0.0) ; cargo build. Aucune modification requise si vous passez par RustwardenApp::builder().
  2. Si vous composez vous-même la pile tower : remplacer new par with_auth_config et passer l'Arc<AuthConfig> que vous posiez déjà en Extension.
  3. Apps headless : vérifier que le 401 sur préfixes protégés et /api/v1/admin* est bien le comportement voulu ; sinon .auth_failure_mode(Redirect) ou AUTH_FAILURE_MODE=redirect.
  4. Les AUTH_FAILURE_MODE / LOGIN_PATH posés uniquement pour contourner le bug peuvent être retirés — ou conservés : ils restent lus par from_env() au démarrage.
  5. Côté consommateur, fermer l'entrée « RFC upstream à ouvrir » de docs/UPSTREAM_RUSTWARDEN.md en pointant vers cette RFC.

Rollback (processus §10)

Régression constatée sur une app headless + SSR : poser AUTH_FAILURE_MODE=redirect (sans redéploiement de code). Si la précédence env s'avérait insuffisante : patch 1.2.1 rétablissant Redirect comme défaut headless sur les middlewares, les constructeurs with_auth_config restant en place.

Documentation à mettre à jour dans le même PR

  • docs/auth-middleware-layering.md : le fallback from_env() devient le chemin legacy ; décrire le seeding, with_auth_config et la précédence setter > env > headless.
  • docs/SERVICES_REFERENCE.md L1333-1335 : précédence réelle des overrides.
  • .env.sample (racine) et template/.env.sample : documenter AUTH_FAILURE_MODE et LOGIN_PATH, aujourd'hui absents (seul JWT_SECRET y figure).
  • Rustdoc de RustwardenBuilder::headless, login_path, auth_failure_mode : énoncer la précédence et les points d'application couverts.
  • app/examples/custom_auth_stack.rs : la pile composée à la main ci-dessus.

Open questions

  • OQ1 — Précédence environnement vs bascule headless : tranchée (setter > env > headless > Redirect), implémentée par le paramètre failure_mode_set_in_env de apply_auth_overrides (fonction pure, six tests unitaires).
  • D1 — Train de livraison de la bascule headless : Alternative A en 1.2.0 (décision mainteneur du 2026-10-02). La rupture de défaut est assumée et documentée dans le CHANGELOG (section ⚠️ BREAKING) ; les consommateurs sont adaptés manuellement au déploiement.
  • OQ2 — Hotfix B en 1.1.1 : sans objet, A livrée directement.
  • OQ3 — Layer Extension(Arc<AuthConfig>) d'app.rs : conservé (redondance sans coût) ; retrait avec les constructeurs dépréciés.
  • OQ4 — tower::Layer dédiés (AuthLayer, AdminLayer) : RFC séparée si un consommateur en exprime le besoin.
  • OQ5 — Signal sur le chemin déprécié : implémenté — tracing::warn! une fois par processus (std::sync::Once) dans resolve_auth_config quand le chemin from_env() est pris.

Écarts d'implémentation par rapport à l'API proposée

  • build() résout l'AuthConfig avant la connexion à la base (étape 0) et non à l'étape 3 : la fonction apply_auth_overrides(&self, ..) emprunte le builder alors que self.db est déplacé à l'étape 1. Aucun effet fonctionnel ; SessionConfig::create_memory_layer reste appelée après les overrides. build(mut self) devient build(self).
  • rustwarden-ui::root_redirect redirige en plus vers auth_config.login_path au lieu de /login codé en dur (cohérent avec l'intention de la RFC : .login_path() honoré partout). Trois tests unitaires dans rustwarden-ui.
  • Test #11 (bout en bout via RustwardenBuilder::build() + oneshot) : non écrit, aucun harnais de ce type n'existe dans le dépôt (il faudrait base, migrations et admin par défaut). Couvert par les tests unitaires du builder (#8), le binaire d'intégration isolé (#6) et le test consommateur BotPlace prefixes_proteges.rs à relancer sans AUTH_FAILURE_MODE.
  • Signal environnement durci (revue finale) : failure_mode_set_in_env n'est vrai que pour une valeur AUTH_FAILURE_MODE valide (redirect / json401). from_env() retombe silencieusement sur Redirect quand la valeur est vide ou mal orthographiée ; sans ce durcissement, une faute de frappe dans l'environnement aurait réintroduit le 303 sur une app headless. Fonction pure RustwardenBuilder::failure_mode_overridden_by_env(raw) testée (None, "", "json-401", "json401", "Redirect").
  • Le tracing::warn! du chemin déprécié est émis après un from_env() réussi, pour ne jamais précéder un from_env failed.
  • build() échoue désormais sur un JWT_SECRET invalide avant la connexion DB, les migrations et l'admin par défaut (fail-fast ; consigné au CHANGELOG).
  • Les deux doctests no_run compilent ; le test d'indépendance à l'environnement vit dans crates/rustwarden-core/tests/auth_service_resolved_config.rs avec quatre scénarios (canonique sans env → 401 / 303 custom ; legacy sans env → 500 ; legacy + Extension externe → honorée ; legacy + env → from_env() et overrides ignorés) plus le bench #[ignore].

Suites à donner (hors périmètre, relevées par la revue finale)

  • Fail-open blacklist dans AdminService (admin_service.rs, is_blacklisted(..).is_ok_and(|b| b)) : une erreur DB laisse passer la requête admin, alors que AuthService est fail-closed (Ok(true) | Err(_) => reject). Préexistant, chemin le plus privilégié : ticket dédié.
  • auth_failure_from_extensions (site 5) garde sa chaîne extension → from_env() sans le warn! Once : la promesse d'observabilité OQ5 ne couvre que les deux middlewares.
  • Cinq copies du littéral AuthConfig à quinze champs dans les tests (dont deux hors rustwarden-core). Proposition : #[doc(hidden)] pub fn for_tests() derrière une feature test-util, à faire avant tout ajout de champ à AuthConfig.
  • Le bench tourne sans souscripteur tracing : les 2,37 µs par appel sous-estiment le coût réel du info! par requête en production.

Tracking issue

À ouvrir sur le dépôt Rustwarden avec les labels bug, area:auth, rfc, consumer:botplace ; lien à reporter ici avant tout passage en Accepted. Origine : docs/UPSTREAM_RUSTWARDEN.md § « Workarounds / besoins upstream » (2026-10-02). Test consommateur de référence : crates/botplace-ui/tests/prefixes_proteges.rs (BotPlace), à relancer sans AUTH_FAILURE_MODE après adoption.


Score d'évaluation (auto-évaluation proposée, à confirmer par les reviewers)

CritèreNote (1-5)Commentaire
Généralité5Tout consommateur headless ou à chemin de login custom ; projets CLI ; huit sites recensés, quatre corrigés, quatre rendus cohérents
Stabilité API3Ajout pur pour les constructeurs ; changement de défaut effectif sur une population résiduelle, atténué par la précédence env et un rollback sans code (D1). 5 avec l'Alternative E
Performance4Retire un from_env() par requête sur les sites 1 à 4. Bench #16 (debug, N = 10 000, 2026-10-02) : from_env() seul 2,37 µs/appel ; pile legacy 40,2 µs/req ; pile with_auth_config 37,7 µs/req, soit −6 % par requête protégée sans JWT, plus la disparition du tracing::info! par requête. Gain mesurable mais modeste
Sécurité4Une seule configuration validée au boot ; plus de dérive via l'environnement à chaud ; chemin 500 fermé sur le câblage canonique. Changement de défaut headless à annoncer
Complexité d'implémentation4~90 lignes sur quatre fichiers + tests ; pattern déjà en place (security_headers_middleware)
Charge de maintenance5Zéro dépendance ; supprime du code (fallbacks) à terme
Documentation4Rustdoc, deux doctests, CHANGELOG, exemple, quatre documents à réviser, .env.sample à compléter
Total29/35≥ 25 (31/35 avec l'Alternative E)

Historique :

  • 2026-10-02 : Draft. Origine BotPlace (docs/UPSTREAM_RUSTWARDEN.md). Câblage vérifié sur main 5df0405 : app.rs L788-831 et L513-526, auth_service.rs L239-258 / L452-484, admin_service.rs L72-90, admin_helper.rs L59, extractors.rs L194-200, auth_required.rs L43-47, config/auth.rs L212-229, rustwarden-ui/handlers/root_handler.rs L48.
  • 2026-10-02 : D1 tranchée (Alternative A, 1.2.0, rupture assumée) ; implémentation livrée le jour même : auth_service.rs, admin_service.rs (7 tests), app.rs (apply_auth_overrides, 6 tests), rustwarden-ui/root_handler.rs (3 tests), tests/auth_service_resolved_config.rs (5 tests + bench), app/examples/custom_auth_stack.rs, CHANGELOG 1.2.0, manifestes 1.2.0, auth-middleware-layering.md, SERVICES_REFERENCE.md, .env.sample, template/.env.sample. Statut → Implemented.
  • 2026-10-02 : revue critique interne (REQUEST CHANGES) intégrée → Under Review. Changements : fonction libre resolve_auth_config (le &self était inaccessible dans le future 'static), mut req, apply_auth_overrides pure avec signal env en paramètre, site 4 (root_redirect) ajouté au périmètre, recensement des huit sites, mocks count() explicites, test d'indépendance à l'environnement en binaire isolé, bench ajouté, Alternative E, checklist breaking restaurée et décision D1 formalisée, fenêtre de dépréciation alignée sur §5, .env.sample au lieu de .env.example, désalignement des manifestes signalé.