FIX renouvelmeent password depuis gitrust

EBO <eric.bouhana@softalys.com> committé le 2026-05-16 17:00

3c5d9464bc7dc35d7e1333c39189715dbedecc90

1 parent(s)

7 fichiers modifiés +800 -11
M CHANGELOG.md
+18 -0
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8 8 ## [Unreleased]
9 9
10 +### Fixed
11 +
12 +- **Middleware `AuthService` — branche optional auth alignée sur la branche
13 + protected** (cf. [`docs/rfcs/0031-auth-service-optional-fallback.md`](docs/rfcs/0031-auth-service-optional-fallback.md),
14 + plan [`docs/plans/2026-05-16-auth-service-optional-fallback.md`](docs/plans/2026-05-16-auth-service-optional-fallback.md)).
15 + La branche optional applique désormais le fallback `AuthConfig::from_env()`
16 + quand l'extension `Extension(Arc<AuthConfig>)` n'est pas encore posée par
17 + les layers downstream — symétrise le comportement avec la branche protected
18 + patchée précédemment par `d4d6828`. Conséquence : tout consommateur qui
19 + mappait des routes auth-required hors `protected_prefixes` (typiquement les
20 + intégrations fédérées qui consomment `Option<Extension<Claims>>`) voit enfin
21 + les claims injectés au lieu de recevoir silencieusement `None` puis d'être
22 + redirigé vers `/login`. Les `register_protected_prefix()` ajoutés
23 + uniquement pour contourner ce bug peuvent être retirés (garder ceux qui
24 + sont là pour **rejeter** les non-authentifiés). Aucun changement d'API
25 + publique. Test de non-régression ajouté dans
26 + [`crates/rustwarden-core/tests/middleware_authconfig_fallback.rs`](crates/rustwarden-core/tests/middleware_authconfig_fallback.rs).
27 +
10 28 ### Changed (BREAKING)
11 29
12 30 - **Extraction du crate `rustwarden-fediverse`** (cf.
M crates/rustwarden-core/src/middleware/auth_service.rs
+62 -1
@@ -28,6 +28,45 @@ pub(crate) fn sanitize_redirect(uri: &str) -> String {
28 28 }
29 29
30 30 /// Tower middleware that injects the authenticated user into request extensions.
31 +///
32 +/// The middleware splits incoming requests into two branches based on
33 +/// whether the request path matches any of the registered
34 +/// `protected_prefixes`:
35 +///
36 +/// - **Protected branch**: authentication is mandatory. A missing or
37 +/// invalid JWT triggers a `303 See Other` redirect to
38 +/// `/login?redirect=<sanitized-path>`. Additional checks run here
39 +/// (JWT blacklist, `must_change_password`, forced 2FA, remote
40 +/// identity revocation).
41 +/// - **Optional auth branch**: authentication is best-effort. The
42 +/// request is allowed through regardless, but when a valid JWT is
43 +/// present the claims are still injected into
44 +/// `request.extensions()` (`Claims` and the JSON-serialised form)
45 +/// so handlers can adapt to authenticated users via
46 +/// `Option<Extension<Claims>>`.
47 +///
48 +/// Both branches resolve the [`crate::config::auth::AuthConfig`]
49 +/// required for JWT verification via the same two-step lookup:
50 +///
51 +/// 1. Read `Extension(Arc<AuthConfig>)` from the request extensions
52 +/// when a downstream layer has posed it (the canonical wiring
53 +/// performed by [`crate::app::RustwardenBuilder::build`]).
54 +/// 2. Otherwise fall back to
55 +/// [`crate::config::auth::AuthConfig::from_env`] so the
56 +/// middleware keeps working when the layer ordering puts the
57 +/// `Extension` layer further inside the stack. The protected
58 +/// branch was patched first (commit `d4d6828`, RFC 0022); the
59 +/// optional branch was aligned by RFC 0031.
60 +///
61 +/// On the protected branch, a failed `from_env` fallback produces a
62 +/// `500 Internal Server Error` after a `tracing::error!`. On the
63 +/// optional branch, the request simply traverses without claims —
64 +/// matching the semantics of an unauthenticated visitor — and a
65 +/// `tracing::error!` records the misconfiguration.
66 +///
67 +/// See [`docs/auth-middleware-layering.md`](../../../../../docs/auth-middleware-layering.md)
68 +/// for the consumer-facing explanation of layer ordering and
69 +/// `register_protected_prefix` usage.
31 70 #[derive(Clone)]
32 71 pub struct AuthService<S> {
33 72 inner: S,
@@ -309,7 +348,29 @@ where
309 348 .unwrap_or(true);
310 349
311 350 if !is_blacklisted {
312 - if let Some(auth_config) = parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() {
351 + // RFC 0031: mirror the protected branch fallback so
352 + // routes outside `protected_prefixes` still see
353 + // injected claims when the `Extension(Arc<AuthConfig>)`
354 + // layer runs *after* `AuthService` in the tower
355 + // ordering. Without this fallback, `claims` was
356 + // silently `None` for all optional-auth routes.
357 + let auth_config = parts
358 + .extensions
359 + .get::<std::sync::Arc<AuthConfig>>()
360 + .cloned()
361 + .or_else(|| {
362 + AuthConfig::from_env()
363 + .map(std::sync::Arc::new)
364 + .map_err(|e| {
365 + tracing::error!(
366 + "AuthService optional_auth: AuthConfig fallback failed: {e}"
367 + );
368 + e
369 + })
370 + .ok()
371 + });
372 +
373 + if let Some(auth_config) = auth_config {
313 374 if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token)
314 375 {
315 376 parts.extensions.insert(claims.clone());
M crates/rustwarden-core/tests/middleware_authconfig_fallback.rs
+49 -9
@@ -1,17 +1,25 @@
1 -//! RFC 0022 — anti-régression : `AuthService` / `AdminService` /
2 -//! `admin_helper` doivent retomber sur `AuthConfig::from_env()` quand
3 -//! le consommateur n'injecte pas `Extension(Arc<AuthConfig>)` *avant*
4 -//! l'auth middleware dans son `ServiceBuilder`. Le commit `d4d6828`
5 -//! avait introduit un `500` silencieux dans ce cas.
1 +//! RFC 0022 + RFC 0031 — anti-régression : `AuthService` /
2 +//! `AdminService` / `admin_helper` doivent retomber sur
3 +//! `AuthConfig::from_env()` quand le consommateur n'injecte pas
4 +//! `Extension(Arc<AuthConfig>)` *avant* l'auth middleware dans son
5 +//! `ServiceBuilder`. Le commit `d4d6828` avait introduit un `500`
6 +//! silencieux dans la branche **protected** (corrigé par RFC 0022) ;
7 +//! la branche **optional auth** restait sans fallback jusqu'à la
8 +//! RFC 0031, ce qui produisait des `claims = None` silencieux sur
9 +//! toutes les routes hors `protected_prefixes`.
6 10 //!
7 -//! Ce fichier vérifie le pré-requis du fallback : `AuthConfig::from_env()`
8 -//! tourne avec un set minimal de variables d'environnement, comme le
9 -//! cas de prod. Si cette assertion casse, la nouvelle branche fallback
10 -//! de `AuthService::call` est inutile.
11 +//! Ce fichier vérifie le pré-requis partagé par les deux branches :
12 +//! `AuthConfig::from_env()` tourne avec un set minimal de variables
13 +//! d'environnement (comme en prod), et la config rechargée reste
14 +//! fonctionnellement cohérente (sign → validate roundtrip). Si l'une
15 +//! de ces assertions casse, le fallback de `AuthService::call` est
16 +//! inutile dans les deux branches.
11 17
12 18 use std::sync::Mutex;
13 19
20 +use rustwarden_core::auth::jwt::JwtService;
14 21 use rustwarden_core::config::auth::AuthConfig;
22 +use uuid::Uuid;
15 23
16 24 static ENV_LOCK: Mutex<()> = Mutex::new(());
17 25
@@ -75,3 +83,35 @@ fn auth_config_from_env_is_idempotent() {
75 83 "AuthConfig::from_env must be deterministic across calls",
76 84 );
77 85 }
86 +
87 +#[test]
88 +fn auth_config_from_env_yields_signature_consistent_loader() {
89 + // RFC 0031 — la branche optional auth de `AuthService` signe et
90 + // valide les JWT avec un `AuthConfig::from_env()` rechargé. Cette
91 + // assertion garantit que deux chargements successifs produisent des
92 + // clefs HMAC fonctionnellement équivalentes : un token émis avec
93 + // `from_env()` doit pouvoir être validé par un autre `from_env()`
94 + // tournant dans le même process. Sans cette propriété, le fallback
95 + // de RFC 0031 émettrait silencieusement `claims = None`.
96 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
97 + set_minimal_env();
98 +
99 + let signer = AuthConfig::from_env().expect("signer load");
100 + let validator = AuthConfig::from_env().expect("validator load");
101 +
102 + let user_id = Uuid::new_v4();
103 + let token = JwtService::generate_token(
104 + &signer,
105 + user_id,
106 + "fallback_user",
107 + Vec::new(),
108 + Vec::new(),
109 + )
110 + .expect("token signing must succeed");
111 +
112 + let claims = JwtService::validate_token(&validator, &token)
113 + .expect("token signed by from_env() must validate against another from_env()");
114 +
115 + assert_eq!(claims.sub, user_id, "validated claims must round-trip the user id");
116 + assert_eq!(claims.username, "fallback_user");
117 +}
A docs/auth-middleware-layering.md
+150 -0
@@ -0,0 +1,150 @@
1 +# Middleware d'auth : ordre des layers et fallback `AuthConfig`
2 +
3 +> Mode Diátaxis : **explanation** (comprendre la mécanique) + un mini
4 +> **how-to** de troubleshooting à la fin. Référence pour les consommateurs
5 +> de `rustwarden-core` qui mappent leurs propres routes auth-required.
6 +
7 +## Pourquoi cette page existe
8 +
9 +Deux RFC se sont succédé pour stabiliser le middleware
10 +[`AuthService`](../crates/rustwarden-core/src/middleware/auth_service.rs) :
11 +
12 +- [RFC 0022 — fallback `AuthConfig::from_env()` côté branche protected](rfcs/0022-smoke-tests-regression.md) (correctif `d4d6828`).
13 +- [RFC 0031 — fallback symétrique côté branche optional auth](rfcs/0031-auth-service-optional-fallback.md).
14 +
15 +Les deux corrigent le même piège : l'ordre des layers tower configuré
16 +par défaut dans [`app.rs`](../crates/rustwarden-core/src/app.rs) place
17 +`AuthService` **avant** l'extension `Extension(Arc<AuthConfig>)`, donc
18 +au moment où le middleware s'exécute l'extension n'est pas encore
19 +disponible dans `request.extensions()`. Sans fallback, le résultat est
20 +soit une erreur 500 silencieuse (avant RFC 0022) soit des
21 +`claims = None` silencieux (avant RFC 0031).
22 +
23 +Cette page documente le comportement final : ce qui est garanti, ce
24 +qui ne l'est pas, et comment câbler ses propres routes.
25 +
26 +## Les deux branches de `AuthService`
27 +
28 +`AuthService::call` choisit son comportement en fonction du préfixe de
29 +chemin demandé :
30 +
31 +| Branche | Condition d'entrée | Mode | Réaction si JWT absent / invalide |
32 +|---|---|---|---|
33 +| **Protected** | `path` commence par un préfixe de `protected_prefixes` | Authentification **obligatoire** | Redirige vers `/login?redirect=…` (303) |
34 +| **Optional auth** | `path` ne matche aucun préfixe protégé | Authentification **best-effort** | Aucune action ; la requête traverse avec `claims = None` |
35 +
36 +Dans les deux branches, dès qu'un JWT est présent et valide les
37 +claims sont injectés dans `request.extensions()` :
38 +
39 +- `Extension<Claims>` (struct fortement typée)
40 +- `Extension<String>` (JSON sérialisé, utile pour les templates Askama)
41 +
42 +Les handlers consomment soit `Extension<Claims>` (la route exige un
43 +user authentifié), soit `Option<Extension<Claims>>` (le handler s'adapte
44 +à un visiteur anonyme).
45 +
46 +## Le pré-requis fallback `AuthConfig::from_env()`
47 +
48 +Pour valider la signature d'un JWT, `AuthService` a besoin du secret
49 +HMAC stocké dans `AuthConfig.jwt_secret`. Deux sources possibles :
50 +
51 +1. **`Extension(Arc<AuthConfig>)`** posée par un layer downstream. C'est
52 + la voie « normale » que [`app.rs`](../crates/rustwarden-core/src/app.rs)
53 + active dans son `ServiceBuilder`.
54 +2. **`AuthConfig::from_env()`** rechargé en fallback. Cette voie
55 + garantit que le middleware reste fonctionnel même si l'ordre des
56 + layers ne pose pas l'extension avant lui.
57 +
58 +Les deux branches (protected et optional) appliquent désormais le
59 +**même** fallback. La conséquence pratique : pourvu que la variable
60 +d'environnement `JWT_SECRET` soit définie (validée au démarrage par
61 +`AuthConfig::from_env()`), le middleware fonctionne indépendamment de
62 +l'ordre exact des layers que le consommateur compose.
63 +
64 +### Coût du fallback
65 +
66 +Négligeable. Le fallback ne s'enclenche que si `Extension(Arc<AuthConfig>)`
67 +est absente, ce qui peut arriver une fois par requête en mode dégradé.
68 +Le parsing env est en O(n) sur quelques variables et reste sous la
69 +microseconde sur des machines modernes.
70 +
71 +Si le fallback `from_env()` lui-même échoue (par exemple `JWT_SECRET`
72 +absent — situation anormale, le serveur n'aurait pas démarré), un
73 +`tracing::error!` est émis :
74 +
75 +```
76 +AuthService optional_auth: AuthConfig fallback failed: <message>
77 +```
78 +
79 +ou pour la branche protected :
80 +
81 +```
82 +AuthService: AuthConfig unavailable (no extension and from_env failed): <message>
83 +```
84 +
85 +Côté branche protected, ce log précède un `500 Internal Server Error`.
86 +Côté branche optional, la requête traverse sans claims (best-effort,
87 +le handler reçoit `claims = None`).
88 +
89 +## Quand ai-je besoin de `register_protected_prefix()` ?
90 +
91 +Le `RustwardenBuilder` expose `register_protected_prefix(prefix)` pour
92 +**ajouter** un chemin à la liste `protected_prefixes`. Deux raisons
93 +légitimes de l'utiliser :
94 +
95 +1. **Rejeter automatiquement les non-authentifiés** sur certaines
96 + routes. Sans préfixe protégé, un visiteur anonyme atteindra le
97 + handler et il devra refuser lui-même via `Extension<Claims>` (qui
98 + provoque un `400 Bad Request` axum) ou un check explicite. Avec
99 + préfixe protégé, le middleware redirige vers `/login` avant même
100 + que le handler tourne.
101 +
102 +2. **Activer les vérifications additionnelles** (must-change-password,
103 + 2FA forcé global, blacklist JWT) qui ne tournent que sur la branche
104 + protected. Si votre route doit refuser un user dont
105 + `must_change_password = true`, elle doit être en protected.
106 +
107 +À l'inverse, **pas besoin** de déclarer un préfixe protégé juste pour
108 +que les claims soient injectés : depuis RFC 0031, la branche optional
109 +suffit.
110 +
111 +## Troubleshooting
112 +
113 +### « Login → /dashboard redirige vers /login sans erreur »
114 +
115 +Symptôme historique du bug RFC 0031 : le cookie JWT est bien posé,
116 +le user est authentifié, mais `/dashboard` (ou toute route hors des
117 +préfixes protégés par défaut) redirige vers `/login`.
118 +
119 +**Diagnostic** :
120 +
121 +1. Confirmer que le cookie est transmis :
122 + ```bash
123 + curl -v -b /tmp/cookies.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|cookie"
124 + ```
125 +2. Vérifier les logs serveur pour `JWT extracted from cookie`.
126 +3. Vérifier la version de `rustwarden-core` : si elle est antérieure
127 + au fix RFC 0031, vous **devez** soit bumper, soit déclarer
128 + `register_protected_prefix("/dashboard")` comme contournement.
129 +
130 +**Après bump RFC 0031** : le fallback automatique injecte les claims
131 +même si l'extension `AuthConfig` n'est pas encore posée par les layers.
132 +Vous pouvez retirer les `register_protected_prefix()` ajoutés
133 +uniquement pour contourner ce bug — **gardez** ceux qui sont là pour
134 +les deux raisons légitimes ci-dessus.
135 +
136 +### « Le serveur renvoie 500 sur /admin sans log clair »
137 +
138 +C'est le symptôme du bug RFC 0022 (corrigé par `d4d6828`). Si vous le
139 +constatez avec une version récente, c'est qu'`AuthConfig::from_env()`
140 +échoue : vérifiez `JWT_SECRET` dans l'environnement. Le log
141 +`AuthService: AuthConfig unavailable (no extension and from_env failed)`
142 +précise l'erreur.
143 +
144 +## Voir aussi
145 +
146 +- [RFC 0022 — fallback protected](rfcs/0022-smoke-tests-regression.md) (commit `d4d6828`)
147 +- [RFC 0031 — fallback optional](rfcs/0031-auth-service-optional-fallback.md)
148 +- Source : [`crates/rustwarden-core/src/middleware/auth_service.rs`](../crates/rustwarden-core/src/middleware/auth_service.rs)
149 +- Tests de non-régression : [`crates/rustwarden-core/tests/middleware_authconfig_fallback.rs`](../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs)
150 +- Guide consommateur fédération : [`fediverse-consumer-guide.md`](fediverse-consumer-guide.md) §4 « Wire-up »
M docs/fediverse-consumer-guide.md
+6 -1
@@ -184,7 +184,12 @@ Points d'attention :
184 184 que le reste du framework — n'ouvrez pas une seconde pool.
185 185 - **Préfixes protégés**. `register_protected_prefix` câble le middleware
186 186 d'auth sur les routes qui exigent une session humaine (consent,
187 - invitations acceptées par un user identifié).
187 + invitations acceptées par un user identifié). **Note RFC 0031** :
188 + depuis ce fix, vous n'avez **pas** besoin de déclarer un préfixe
189 + protégé juste pour que `Option<Extension<Claims>>` soit alimenté dans
190 + vos handlers — le middleware injecte les claims des deux côtés.
191 + Réservez `register_protected_prefix` aux routes qui doivent **rejeter**
192 + les non-authentifiés. Détail dans [`auth-middleware-layering.md`](auth-middleware-layering.md).
188 193 - **Revocation checker injecté**. Sans cet appel, un `Delete{Actor}`
189 194 entrant n'invalide pas les JWT déjà émis pour ce remote.
190 195
A docs/plans/2026-05-16-auth-service-optional-fallback.md
+147 -0
@@ -0,0 +1,147 @@
1 +# Plan — RFC 0031 : aligner le fallback `AuthConfig::from_env()` dans la branche optional de `AuthService`
2 +
3 +- **Date** : 2026-05-16
4 +- **RFC** : [docs/rfcs/0031-auth-service-optional-fallback.md](../rfcs/0031-auth-service-optional-fallback.md)
5 +- **Alternative retenue** : A (fallback symétrique dans la branche optional)
6 +- **Statut** : Approuvé (utilisateur, 2026-05-16)
7 +- **Type** : bug-fix, pas de breaking change
8 +
9 +## Contexte (résumé)
10 +
11 +Le middleware [`AuthService`](../../crates/rustwarden-core/src/middleware/auth_service.rs) a deux branches dans `call()` :
12 +
13 +- **Protected** (lignes 137-156) : si `Extension(Arc<AuthConfig>)` est absente du `request.extensions()` (cas réel quand l'ordre des layers tower place `AuthService` plus à l'extérieur que `.layer(Extension(auth_config))`), bascule sur `AuthConfig::from_env()`.
14 +- **Optional** (lignes 312-320) : **pas de fallback**. Si l'extension n'est pas posée, on saute silencieusement la validation du JWT → `claims` jamais injecté → toute route auth-required hors `protected_prefixes` reçoit `Option<Extension<Claims>>::None` et redirige vers `/login`.
15 +
16 +L'ordre des layers défini dans [`app.rs:559-578`](../../crates/rustwarden-core/src/app.rs#L559-L578) garantit que la branche optional touche systématiquement le cas non-fallbacké. Le bug a été reproduit côté gitrust pendant l'intégration fediverse (2026-05-16).
17 +
18 +## Étapes d'exécution
19 +
20 +### 1. Patch `auth_service.rs` (Alternative A — fallback symétrique)
21 +
22 +Cible : [crates/rustwarden-core/src/middleware/auth_service.rs](../../crates/rustwarden-core/src/middleware/auth_service.rs), lignes 311-321 (branche `else { ... }`).
23 +
24 +Avant :
25 +
26 +```rust
27 +if !is_blacklisted {
28 + if let Some(auth_config) = parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() {
29 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) {
30 + parts.extensions.insert(claims.clone());
31 + if let Ok(json) = serde_json::to_string(&claims) {
32 + parts.extensions.insert(json);
33 + }
34 + }
35 + }
36 +}
37 +```
38 +
39 +Après :
40 +
41 +```rust
42 +if !is_blacklisted {
43 + // Symétrie avec la branche protected (cf. lignes 137-156, regression
44 + // d4d6828) : si `Extension(Arc<AuthConfig>)` n'est pas encore posée
45 + // par les layers downstream, on bascule sur `AuthConfig::from_env()`
46 + // pour que les claims soient quand même injectés. Sans ce fallback,
47 + // toute route hors `protected_prefixes` voit silencieusement
48 + // claims = None.
49 + let auth_config = parts
50 + .extensions
51 + .get::<std::sync::Arc<AuthConfig>>()
52 + .cloned()
53 + .or_else(|| {
54 + AuthConfig::from_env()
55 + .map(std::sync::Arc::new)
56 + .map_err(|e| {
57 + tracing::error!(
58 + "AuthService optional_auth: AuthConfig fallback failed: {e}"
59 + );
60 + e
61 + })
62 + .ok()
63 + });
64 +
65 + if let Some(auth_config) = auth_config {
66 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) {
67 + parts.extensions.insert(claims.clone());
68 + if let Ok(json) = serde_json::to_string(&claims) {
69 + parts.extensions.insert(json);
70 + }
71 + }
72 + }
73 +}
74 +```
75 +
76 +**Décision sur le log** : on conserve `tracing::error!` uniquement en cas d'échec de `from_env()`, strictement symétrique à la branche protected. Le happy path (fallback réussi) reste silencieux. Pas de warning supplémentaire (évite le bruit en dev où l'ordre des layers actuel déclenche systématiquement le fallback).
77 +
78 +### 2. Tests de régression
79 +
80 +Le fichier [`tests/middleware_authconfig_fallback.rs`](../../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) couvre déjà le pré-requis (`AuthConfig::from_env()` fonctionne avec un set minimal d'env vars). Ce pré-requis est partagé par les deux branches.
81 +
82 +Ajouts ciblés sur ce même fichier (pas de nouveau fichier) :
83 +
84 +1. **Mise à jour de l'en-tête de module** : préciser que le pré-requis couvre désormais aussi la branche optional (RFC 0031), pas seulement la branche protected (RFC 0022).
85 +2. **Nouveau test `auth_config_from_env_yields_signature_consistent_loader`** : signer un JWT avec un `AuthConfig::from_env()` puis le valider avec un `AuthConfig::from_env()` rechargé, prouvant que le fallback produit un config fonctionnellement utilisable pour la validation JWT (propriété clef du chemin nouvellement actif en optional).
86 +
87 +Le test end-to-end de la branche optional (Service `tower` complet, DB, cookies) reste hors scope — il demanderait `MockDatabase` (non utilisée dans `rustwarden-core` à ce jour) ou un harness HTTP complet. Le reproducer §Motivation de la RFC sert de test e2e manuel.
88 +
89 +### 3. Entrée CHANGELOG
90 +
91 +Ajouter une section `### Fixed` sous `## [Unreleased]` dans [CHANGELOG.md](../../CHANGELOG.md) (n'existe pas encore — placer après `### Added`) :
92 +
93 +```markdown
94 +### Fixed
95 +
96 +- **Middleware `AuthService` — branche optional auth alignée sur la branche
97 + protected** (cf. [`docs/rfcs/0031-auth-service-optional-fallback.md`](docs/rfcs/0031-auth-service-optional-fallback.md)).
98 + La branche optional applique désormais le fallback `AuthConfig::from_env()`
99 + quand l'extension `Extension(Arc<AuthConfig>)` n'est pas encore posée par
100 + les layers downstream — symétrise le comportement avec la branche protected
101 + patchée précédemment par `d4d6828`. Conséquence : tout consommateur qui
102 + mappait des routes auth-required hors `protected_prefixes` (typiquement les
103 + intégrations fédérées qui consomment `Option<Extension<Claims>>`) voit enfin
104 + les claims injectés. Les `register_protected_prefix()` ajoutés uniquement
105 + pour contourner ce bug peuvent être retirés (garder ceux qui sont là pour
106 + rejeter les non-authentifiés). Aucun changement d'API publique.
107 +```
108 +
109 +### 4. Vérifications
110 +
111 +```bash
112 +# Build + lint ciblés
113 +cargo build -p rustwarden-core
114 +cargo clippy -p rustwarden-core --all-targets -- -D warnings
115 +
116 +# Tests ciblés
117 +cargo test -p rustwarden-core --test middleware_authconfig_fallback
118 +
119 +# Sanity workspace (sans la feature redis-session connue cassée)
120 +cargo build --workspace
121 +cargo clippy --workspace -- -D warnings
122 +```
123 +
124 +### 5. Statut RFC
125 +
126 +Bascule du frontmatter `Statut : Draft` → `Statut : Accepted` dans `docs/rfcs/0031-auth-service-optional-fallback.md`. Ajout d'une ligne d'historique avec la date d'implémentation.
127 +
128 +## Hors scope (rappel des non-goals RFC §Non-goals)
129 +
130 +- Aucune modification de signature `AuthService::new` (Alternative C rejetée — gardée pour un futur major).
131 +- Aucune modification de l'ordre des layers dans `app.rs` (Alternative B rejetée — défensif vs side-effects).
132 +- La liste `protected_prefixes` n'est pas supprimée : elle reste utile pour **rejeter** les non-authentifiés, ce que la branche optional ne fait pas.
133 +
134 +## Open questions tranchées
135 +
136 +- **Niveau de log du fallback** → `tracing::error!` uniquement en échec, silencieux en succès (symétrie protected, pas de spam en dev).
137 +- **Avertissement ordre des layers dans `app.rs`** → non ajouté (le fallback rend le sujet non-bloquant ; à reconsidérer si une RFC ultérieure stabilise l'ordre).
138 +
139 +## Estimation
140 +
141 +- Patch + test ciblé + CHANGELOG : ~20 min.
142 +- Build + clippy + test : ~5 min.
143 +- Validation manuelle reproducer côté gitrust : à la charge du consommateur.
144 +
145 +## Tracking
146 +
147 +À l'issue de l'implémentation, basculer le statut de la RFC vers `Accepted` et créer (si demandé) l'issue de tracking sur le repo (cf. RFC §Tracking issue).
A docs/rfcs/0031-auth-service-optional-fallback.md
+368 -0
@@ -0,0 +1,368 @@
1 +# RFC 0004 — Aligner le fallback `AuthConfig::from_env()` dans `AuthService` (branche optional auth)
2 +
3 +- **Auteur** : EBO (gitrust)
4 +- **Date** : 2026-05-16
5 +- **Statut** : Accepted (implémenté 2026-05-16)
6 +- **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.
7 +- **Crate cible** : `rustwarden-core` (correction d'un bug interne au middleware `AuthService`)
8 +- **Type** : bug-fix RFC (pas de changement d'API publique)
9 +
10 +## Summary
11 +
12 +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`.
13 +
14 +## Motivation
15 +
16 +### Bug observé
17 +
18 +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**.
19 +
20 +Reproduction minimum :
21 +
22 +```bash
23 +# 1. Reset password via CLI (clear must_change_password) :
24 +cargo run -- admin reset-password --username admin
25 +
26 +# 2. Démarrer le serveur :
27 +cargo run
28 +
29 +# 3. Login via curl :
30 +curl -v -c /tmp/c.txt -X POST http://localhost:4000/login \
31 + -d "username=admin&password=PASS" 2>&1 | grep -E "HTTP|location|set-cookie"
32 +# < HTTP/1.1 303 See Other
33 +# < location: /dashboard
34 +# < set-cookie: jwt_token=...; HttpOnly; SameSite=Lax; Path=/
35 +
36 +# 4. GET /dashboard avec le cookie :
37 +curl -v -b /tmp/c.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|location"
38 +# < HTTP/1.1 303 See Other ← LE BUG
39 +# < location: /login?redirect=%2Fdashboard
40 +```
41 +
42 +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`.
43 +
44 +### Root cause
45 +
46 +Le middleware `AuthService` ([crates/rustwarden-core/src/middleware/auth_service.rs](../../crates/rustwarden-core/src/middleware/auth_service.rs)) a deux branches :
47 +
48 +**Branche protected route** (path matche un `protected_prefix`) — ligne 137-141 :
49 +```rust
50 +let auth_config = match parts.extensions.get::<Arc<AuthConfig>>().cloned() {
51 + Some(c) => c,
52 + None => Arc::new(AuthConfig::from_env().map_err(|e| {
53 + tracing::error!("AuthConfig fallback failed: {e}");
54 + AppError::Internal("AuthConfig unavailable".into())
55 + })?),
56 +};
57 +```
58 +
59 +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.
60 +
61 +**Branche optional auth** (route non-protégée) — ligne 312-321 :
62 +```rust
63 +if let Some(auth_config) = parts.extensions.get::<Arc<AuthConfig>>().cloned() {
64 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) {
65 + parts.extensions.insert(claims.clone());
66 + // ...
67 + }
68 +}
69 +// ← Pas de fallback. Si auth_config absent → skip silencieux.
70 +```
71 +
72 +**Aucun fallback**. Et selon l'ordre des layers tower configuré dans `app.rs:556-578` :
73 +
74 +```rust
75 +ServiceBuilder::new()
76 + .layer(AuthService::layer_fn(...)) // outermost (premier inbound)
77 + .layer(session_layer)
78 + .layer(locale_middleware)
79 + .layer(axum::Extension(i18n_service))
80 + .layer(axum::Extension(hooks))
81 + .layer(axum::Extension(auth_config)) // innermost (set après AuthService)
82 + .layer(axum::Extension(db_for_ext))
83 + .into_inner()
84 +```
85 +
86 +`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.
87 +
88 +### Impact pour les consommateurs
89 +
90 +Tout consommateur qui :
91 +1. Active `experimental-fediverse` ou tout autre crate qui mount des routes auth-required en utilisant `Option<Extension<Claims>>` côté handler.
92 +2. Expose des routes auth-required **hors** des `protected_prefixes` par défaut (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`).
93 +
94 +→ Les claims ne sont jamais injectés. Le user reste invisible. Le handler refuse l'auth. Redirect /login.
95 +
96 +**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.
97 +
98 +### Pourquoi ce n'est pas attrapé par les tests framework
99 +
100 +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.
101 +
102 +### Urgence
103 +
104 +**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.
105 +
106 +## Non-goals
107 +
108 +- **Ne PAS modifier** l'API publique de `AuthService::new`.
109 +- **Ne PAS modifier** l'ordre des layers dans `app.rs` (changer l'ordre pourrait casser d'autres choses, le fallback est plus sûr).
110 +- **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).
111 +
112 +## API proposée
113 +
114 +**Aucun changement d'API publique.** Modification interne d'une dizaine de lignes dans `crates/rustwarden-core/src/middleware/auth_service.rs`.
115 +
116 +### Avant (l. ~292-326)
117 +
118 +```rust
119 +} else {
120 + // SEC-H9: For non-protected routes, still try to extract and propagate
121 + // JWT claims into extensions so that handlers/templates can detect the
122 + // logged-in user (optional auth).
123 + let db = self.db.clone();
124 + let mut inner = self.inner.clone();
125 + Box::pin(async move {
126 + let (mut parts, body) = req.into_parts();
127 +
128 + if let Ok(jwt_token) =
129 + crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts)
130 + {
131 + use crate::auth::jwt::JwtService;
132 + use crate::config::auth::AuthConfig;
133 + use crate::services::jwt_blacklist_service::JwtBlacklistService;
134 +
135 + let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token)
136 + .await
137 + .unwrap_or(true);
138 +
139 + if !is_blacklisted {
140 + if let Some(auth_config) = parts.extensions.get::<std::sync::Arc<AuthConfig>>().cloned() {
141 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) {
142 + parts.extensions.insert(claims.clone());
143 + if let Ok(json) = serde_json::to_string(&claims) {
144 + parts.extensions.insert(json);
145 + }
146 + }
147 + }
148 + }
149 + }
150 +
151 + let req = Request::from_parts(parts, body);
152 + inner.call(req).await
153 + })
154 +}
155 +```
156 +
157 +### Après
158 +
159 +```rust
160 +} else {
161 + // SEC-H9: For non-protected routes, still try to extract and propagate
162 + // JWT claims into extensions so that handlers/templates can detect the
163 + // logged-in user (optional auth).
164 + let db = self.db.clone();
165 + let mut inner = self.inner.clone();
166 + Box::pin(async move {
167 + let (mut parts, body) = req.into_parts();
168 +
169 + if let Ok(jwt_token) =
170 + crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts)
171 + {
172 + use crate::auth::jwt::JwtService;
173 + use crate::config::auth::AuthConfig;
174 + use crate::services::jwt_blacklist_service::JwtBlacklistService;
175 +
176 + let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token)
177 + .await
178 + .unwrap_or(true);
179 +
180 + if !is_blacklisted {
181 + // Align with the protected branch: when the Extension(AuthConfig)
182 + // layer hasn't run yet (due to tower layer ordering), fall back
183 + // to AuthConfig::from_env() so the claims can still be validated
184 + // and injected. Without this fallback, all routes outside
185 + // `protected_prefixes` silently see claims = None.
186 + let auth_config = parts
187 + .extensions
188 + .get::<std::sync::Arc<AuthConfig>>()
189 + .cloned()
190 + .or_else(|| {
191 + AuthConfig::from_env()
192 + .map(std::sync::Arc::new)
193 + .map_err(|e| {
194 + tracing::error!(
195 + "AuthService optional_auth: AuthConfig fallback failed: {e}"
196 + );
197 + e
198 + })
199 + .ok()
200 + });
201 +
202 + if let Some(auth_config) = auth_config {
203 + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) {
204 + parts.extensions.insert(claims.clone());
205 + if let Ok(json) = serde_json::to_string(&claims) {
206 + parts.extensions.insert(json);
207 + }
208 + }
209 + }
210 + }
211 + }
212 +
213 + let req = Request::from_parts(parts, body);
214 + inner.call(req).await
215 + })
216 +}
217 +```
218 +
219 +**Diff réel** : ~12 lignes de Rust ajoutées (l'`or_else` + le log d'erreur). Le reste du flow est identique.
220 +
221 +## Analyse de compatibilité
222 +
223 +- [x] **API publique non modifiée** : `AuthService::new`, signatures du middleware, types `Claims` — tout reste identique.
224 +- [x] **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.
225 +- [x] **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.
226 +- [x] **Schéma DB** : pas de changement.
227 +- [x] **Format sérialisé** : pas de changement.
228 +
229 +**Verdict** : 100 % additif comportemental, bug fix pur.
230 +
231 +## Alternatives considérées
232 +
233 +### Alternative A (recommandée) : fallback symétrique dans la branche optional
234 +
235 +Voir ci-dessus. Symétrie avec la branche protected. Robuste.
236 +
237 +### Alternative B : changer l'ordre des layers dans `app.rs`
238 +
239 +Mettre `AuthService` **après** les extensions Extension(AuthConfig)/db dans le ServiceBuilder :
240 +
241 +**Pros** : pas de fallback à maintenir, ordre clair.
242 +
243 +**Cons** :
244 +- Risque de side-effects sur d'autres middlewares qui dépendent de l'ordre actuel (session_layer, locale).
245 +- Si un consommateur custom l'ordre via une API future, le bug réapparait. Le fallback est défensif.
246 +- Change le contrat documenté d'ordre des layers — potentiel breaking pour des consommateurs avancés.
247 +
248 +### Alternative C : forcer `AuthConfig` dans le state de `AuthService::new`
249 +
250 +Passer `Arc<AuthConfig>` comme paramètre du constructeur, l'utiliser directement (pas de extensions lookup).
251 +
252 +**Pros** : élimine le risque d'ordre des layers définitivement.
253 +
254 +**Cons** :
255 +- **Breaking change** sur le constructeur (signature publique modifiée).
256 +- Tous les consommateurs qui appellent `AuthService::new` directement doivent adapter (probablement peu de consommateurs, mais c'est un changement non-additif).
257 +- À garder pour un future major.
258 +
259 +### Alternative D : ne rien faire (statu quo)
260 +
261 +**Pros** : zéro travail framework.
262 +
263 +**Cons** :
264 +- Chaque consommateur futur qui rencontre ce bug perd 1-2h de debug (cf. session gitrust 2026-05-16).
265 +- Documentation implicite : "tu dois déclarer en `protected_prefix` toute route auth-required" — non-évident.
266 +- Mauvaise UX dev framework.
267 +
268 +**Choix** : Alternative A. Bug fix minimal, non-breaking, robuste.
269 +
270 +## Testing plan
271 +
272 +### Unit
273 +
274 +Ajouter un test dans `crates/rustwarden-core/src/middleware/auth_service.rs#tests` :
275 +
276 +```rust
277 +#[tokio::test]
278 +async fn optional_auth_with_missing_extension_falls_back_to_env() {
279 + // Setup: env JWT_SECRET valide, mais pas d'Extension(AuthConfig) dans la request
280 + std::env::set_var("JWT_SECRET", "0".repeat(64));
281 +
282 + let req = test_request_with_jwt_cookie_only(/* valid token */);
283 + let response = AuthService::call(req).await;
284 +
285 + // Le claims doit être présent dans response.extensions()
286 + assert!(response.extensions().get::<Claims>().is_some());
287 +}
288 +
289 +#[tokio::test]
290 +async fn optional_auth_with_extension_uses_it_preferentially() {
291 + let custom_config = Arc::new(AuthConfig { /* non-default values */ });
292 + let req = test_request_with_jwt_cookie_and_ext(token, custom_config.clone());
293 + let response = AuthService::call(req).await;
294 +
295 + // Le claims doit être présent ET validé avec custom_config (pas from_env)
296 + assert!(response.extensions().get::<Claims>().is_some());
297 +}
298 +
299 +#[tokio::test]
300 +async fn optional_auth_with_no_jwt_passes_through() {
301 + let req = test_request_without_jwt();
302 + let response = AuthService::call(req).await;
303 +
304 + // claims absent — comportement inchangé pour les visiteurs non authentifiés
305 + assert!(response.extensions().get::<Claims>().is_none());
306 +}
307 +```
308 +
309 +### Integration
310 +
311 +Test avec un router minimal :
312 +- Route `GET /test` (non-protected, handler avec `Option<Extension<Claims>>`)
313 +- Layer ordering reproductible (Extension(AuthConfig) après AuthService)
314 +- Login via POST /login pour générer un cookie
315 +- Vérifier que `GET /test` renvoie 200 avec claims présent
316 +
317 +### Regression
318 +
319 +Vérifier que tous les tests existants passent (le fallback ne touche pas la branche protected déjà fonctionnelle).
320 +
321 +### Compat
322 +
323 +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.
324 +
325 +## Migration guide
326 +
327 +À copier dans le `CHANGELOG.md` de `rustwarden-core` au merge :
328 +
329 +### Fixed (0.X+1)
330 +
331 +- 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`.
332 +
333 +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é).
334 +
335 +### Migration steps
336 +
337 +Aucune action obligatoire. Recommandation :
338 +1. Bump `rustwarden-core` à la version contenant le fix.
339 +2. **Auditer** ses `register_protected_prefix()` : ceux ajoutés uniquement pour contourner ce bug peuvent être retirés.
340 +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).
341 +
342 +## Open questions
343 +
344 +- [ ] **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.
345 +- [ ] **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.
346 +
347 +## Score sur la grille du processus §4
348 +
349 +| Critère | Note | Justification |
350 +|---|---|---|
351 +| **Généralité** | 5 | Tout consommateur qui mappe des routes auth-required hors protected_prefixes bénéficie |
352 +| **Stabilité API** | 5 | Aucun changement d'API publique |
353 +| **Performance** | 5 | Identique en happy path (extension présente) ; +1 env parse en fallback (négligeable) |
354 +| **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) |
355 +| **Complexité impl** | 5 | ~12 lignes de Rust, pattern existant |
356 +| **Maintenance** | 5 | Zéro nouvelle dépendance |
357 +| **Documentation** | 5 | Rustdoc + commentaire inline + entrée CHANGELOG explicite |
358 +| **Total** | **34/35** | ≥ 30 = accélération possible |
359 +
360 +## Tracking issue
361 +
362 +À 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.
363 +
364 +---
365 +
366 +**Historique** :
367 +- 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.
368 +- 2026-05-16 : Accepted + implémenté (Alternative A). Patch dans [`crates/rustwarden-core/src/middleware/auth_service.rs`](../../crates/rustwarden-core/src/middleware/auth_service.rs) (branche optional, ~14 lignes). Test de non-régression étendu dans [`crates/rustwarden-core/tests/middleware_authconfig_fallback.rs`](../../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) (en-tête mise à jour + nouveau `auth_config_from_env_yields_signature_consistent_loader`). Entrée `### Fixed` ajoutée au [CHANGELOG](../../CHANGELOG.md). Plan d'exécution archivé dans [`docs/plans/2026-05-16-auth-service-optional-fallback.md`](../plans/2026-05-16-auth-service-optional-fallback.md).