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). |
GitRust