Set retour pour erreur jwt headless avec redirect ou json401

EBO <eric@ebii.fr> committé le 2026-10-02 21:42

45f9ed39cff2c27d2faea81e69e0a4aad7aba561

1 parent(s)

20 fichiers modifiés +2123 -833
M CHANGELOG.md
+73 -0
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8 8 ## [Unreleased]
9 9
10 +_Rien pour l'instant._
11 +
12 +## [1.2.0] - 2026-10-02 — Breaking (RFC 0042) + Security (RFC 0039 phase 2)
13 +
14 +Minor assumé **breaking** sur un comportement par défaut (décision D1 du
15 +2026-10-02, [`docs/rfcs/0042-auth-middleware-resolved-auth-config.md`](docs/rfcs/0042-auth-middleware-resolved-auth-config.md)) :
16 +les consommateurs sont adaptés **manuellement au déploiement**. Les sept
17 +manifestes `Cargo.toml` du workspace passent à `1.2.0` (ils étaient restés à
18 +`1.0.0` malgré les trains 1.0.1 et 1.1.0).
19 +
20 +### ⚠️ BREAKING — RFC 0042 : les overrides du builder atteignent enfin les middlewares de préfixe
21 +
22 +`AuthService` (préfixes enregistrés via `register_protected_prefix`) et
23 +`AdminService` (`/admin*`, `/api/v1/admin*`) étaient posés **avant** le layer
24 +`Extension(Arc<AuthConfig>)` dans `RustwardenBuilder::build()` ; ils retombaient
25 +donc à chaque requête sur `AuthConfig::from_env()` et ignoraient
26 +`.login_path(..)`, `.auth_failure_mode(..)` et la bascule `Json401` de
27 +`.headless()`. Ils reçoivent désormais la configuration résolue à la
28 +construction.
29 +
30 +**Ce qui change pour vous :**
31 +
32 +| Votre câblage | Avant 1.2.0 (bug) | Depuis 1.2.0 |
33 +|---|---|---|
34 +| `with_ui(..)` | `303 → /login` | inchangé |
35 +| `.headless()` + `.auth_failure_mode(Redirect)` (ex. BotPlace) | `303` (par l'env uniquement) | `303` (par le builder, comme documenté) |
36 +| `.headless()` + `AUTH_FAILURE_MODE=redirect` | `303` | `303` (l'env prime désormais sur la bascule `headless`) |
37 +| `.headless()` sans setter ni env | **`303 → /login`** vers une page inexistante | **`401 {"error":"unauthorized"}`** sur préfixes protégés et `/api/v1/admin*` |
38 +| `.login_path("/x")` | ignoré par les middlewares | honoré partout |
39 +
40 +**Précédence** (nouvelle, explicite) : `.auth_failure_mode(..)` > `AUTH_FAILURE_MODE` >
41 +bascule `.headless()` > `Redirect`.
42 +
43 +**Intervention consommateur au déploiement** : si votre app `.headless()` sert une
44 +UI SSR et comptait sur le `303`, ajoutez `.auth_failure_mode(AuthFailureMode::Redirect)`
45 +au builder (ou `AUTH_FAILURE_MODE=redirect` dans l'environnement — rollback sans
46 +redéploiement de code). Si vous composez votre pile tower à la main, passez à
47 +`AuthService::with_auth_config` / `AdminService::with_auth_config`.
48 +
49 +### Fixed
50 +
51 +- **RFC 0042.** `AuthService` et `AdminService` honorent `RustwardenBuilder::login_path`,
52 + `::auth_failure_mode` et la bascule `Json401` de `::headless()`. Préfixes protégés,
53 + `/admin*` et extracteurs `AuthUser` / `auth_required` partagent la même
54 + configuration : les middlewares la posent eux-mêmes dans les extensions de la
55 + requête, ce qui corrige aussi les piles tower composées à la main.
56 +- `rustwarden-ui::root_redirect` (`GET /`) lit la configuration injectée avant de
57 + retomber sur l'environnement, et redirige vers `login_path` au lieu de `/login`
58 + codé en dur.
59 +
60 +### Changed
61 +
62 +- `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les middlewares
63 + de préfixe ni par `root_redirect` ; le log `Auth configuration loaded` n'apparaît
64 + plus qu'au démarrage dans le câblage canonique.
65 +- Variables `AUTH_FAILURE_MODE` et `LOGIN_PATH` documentées dans `.env.sample` et
66 + `template/.env.sample`.
67 +- `RustwardenBuilder::build()` résout l'`AuthConfig` **avant** la connexion à la
68 + base, les migrations et la création de l'admin par défaut : un `JWT_SECRET` absent
69 + ou placeholder fait échouer `build()` immédiatement (fail-fast), là où ces trois
70 + étapes s'exécutaient auparavant d'abord. L'ordre des logs de démarrage change en
71 + conséquence.
72 +- Une valeur `AUTH_FAILURE_MODE` vide ou invalide est ignorée (avertissement au
73 + démarrage) et **ne supprime pas** la bascule `Json401` de `.headless()` : seule une
74 + valeur valide (`redirect` / `json401`) compte comme override opérateur.
75 +
76 +### Deprecated
77 +
78 +- `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`.
79 +- `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`.
80 + Le chemin déprécié émet un `tracing::warn!` une fois par processus. Suppression au
81 + plus tôt en 1.5.0, effective dans la prochaine major.
82 +
10 83 ### Security
11 84
12 85 - **RFC 0039 phase 2.** `SecurityHeadersConfig::default` n'émet plus
M Cargo.lock
+7 -7
@@ -4215,7 +4215,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
4215 4215
4216 4216 [[package]]
4217 4217 name = "rustwarden-app"
4218 -version = "1.0.0"
4218 +version = "1.2.0"
4219 4219 dependencies = [
4220 4220 "anyhow",
4221 4221 "async-trait",
@@ -4237,7 +4237,7 @@ dependencies = [
4237 4237
4238 4238 [[package]]
4239 4239 name = "rustwarden-cli"
4240 -version = "1.0.0"
4240 +version = "1.2.0"
4241 4241 dependencies = [
4242 4242 "chrono",
4243 4243 "clap",
@@ -4248,7 +4248,7 @@ dependencies = [
4248 4248
4249 4249 [[package]]
4250 4250 name = "rustwarden-core"
4251 -version = "1.0.0"
4251 +version = "1.2.0"
4252 4252 dependencies = [
4253 4253 "aes-gcm",
4254 4254 "anyhow",
@@ -4301,7 +4301,7 @@ dependencies = [
4301 4301
4302 4302 [[package]]
4303 4303 name = "rustwarden-fediverse"
4304 -version = "1.0.0"
4304 +version = "1.2.0"
4305 4305 dependencies = [
4306 4306 "activitypub_federation",
4307 4307 "ammonia",
@@ -4337,7 +4337,7 @@ dependencies = [
4337 4337
4338 4338 [[package]]
4339 4339 name = "rustwarden-fediverse-guard"
4340 -version = "1.0.0"
4340 +version = "1.2.0"
4341 4341 dependencies = [
4342 4342 "askama",
4343 4343 "async-trait",
@@ -4359,7 +4359,7 @@ dependencies = [
4359 4359
4360 4360 [[package]]
4361 4361 name = "rustwarden-fediverse-ui"
4362 -version = "1.0.0"
4362 +version = "1.2.0"
4363 4363 dependencies = [
4364 4364 "askama",
4365 4365 "async-trait",
@@ -4385,7 +4385,7 @@ dependencies = [
4385 4385
4386 4386 [[package]]
4387 4387 name = "rustwarden-ui"
4388 -version = "1.0.0"
4388 +version = "1.2.0"
4389 4389 dependencies = [
4390 4390 "askama",
4391 4391 "async-trait",
M app/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-app"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Reference application using the Rustwarden framework"
A app/examples/custom_auth_stack.rs
+88 -0
@@ -0,0 +1,88 @@
1 +//! Hand-composed tower stack (RFC 0042)
2 +//!
3 +//! Shows how to wire `AuthService` / `AdminService` yourself, outside
4 +//! `RustwardenApp::builder()`, while still honouring a resolved
5 +//! `AuthConfig` (custom `login_path`, explicit `auth_failure_mode`).
6 +//! The middlewares seed `Extension(Arc<AuthConfig>)` into every request,
7 +//! so no `Extension` layer is needed for the extractors (`AuthUser`,
8 +//! `auth_required`, `admin_required`) to see the same instance.
9 +//!
10 +//! ```bash
11 +//! DATABASE_URL=postgres://... JWT_SECRET=... cargo run -p rustwarden-app --example custom_auth_stack
12 +//! curl -si http://localhost:3000/api/private/whoami | head -1 # → HTTP/1.1 401 Unauthorized
13 +//! curl -si http://localhost:3000/api/v1/admin/ping | head -1 # → HTTP/1.1 401 Unauthorized
14 +//! ```
15 +
16 +use std::sync::Arc;
17 +
18 +use axum::{Json, Router, routing::get};
19 +use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode};
20 +use rustwarden_core::config::database;
21 +use rustwarden_core::middleware::admin_service::AdminService;
22 +use rustwarden_core::middleware::auth_service::AuthService;
23 +use sea_orm::DatabaseConnection;
24 +use serde_json::json;
25 +use tower::ServiceBuilder;
26 +
27 +#[tokio::main]
28 +async fn main() -> anyhow::Result<()> {
29 + tracing_subscriber::fmt::init();
30 +
31 + let db: DatabaseConnection = database::create_connection().await?;
32 +
33 + // Resolve the configuration once; apply your own overrides here.
34 + let mut auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?;
35 + auth_config.auth_failure_mode = AuthFailureMode::Json401;
36 + auth_config.login_path = "/auth/signin".to_string();
37 + let auth_config = Arc::new(auth_config);
38 +
39 + let protected_prefixes = Arc::new(vec!["/api/private".to_string()]);
40 +
41 + let app = Router::new()
42 + .route("/api/status", get(status))
43 + .route("/api/private/whoami", get(whoami))
44 + .route("/api/v1/admin/ping", get(admin_ping))
45 + .layer(
46 + ServiceBuilder::new()
47 + .layer_fn({
48 + let (db, cfg) = (db.clone(), auth_config.clone());
49 + move |inner| AdminService::with_auth_config(inner, db.clone(), cfg.clone())
50 + })
51 + .layer_fn({
52 + let (db, cfg, prefixes) =
53 + (db.clone(), auth_config.clone(), protected_prefixes.clone());
54 + move |inner| {
55 + AuthService::with_auth_config(
56 + inner,
57 + db.clone(),
58 + prefixes.clone(),
59 + cfg.clone(),
60 + )
61 + }
62 + })
63 + .into_inner(),
64 + );
65 +
66 + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
67 + tracing::info!("custom_auth_stack listening on http://0.0.0.0:3000");
68 + axum::serve(listener, app).await?;
69 + Ok(())
70 +}
71 +
72 +async fn status() -> Json<serde_json::Value> {
73 + Json(json!({ "status": "ok", "stack": "custom" }))
74 +}
75 +
76 +/// Reached only with a valid JWT carrying the `admin` role: `/api/v1/admin*`
77 +/// is enforced by `AdminService` (anonymous → `401`, non-admin → `403`).
78 +async fn admin_ping() -> Json<serde_json::Value> {
79 + Json(json!({ "admin": "pong" }))
80 +}
81 +
82 +/// Reached only with a valid JWT: `/api/private` is a protected prefix, so
83 +/// anonymous requests get `401 {"error":"unauthorized"}` from `AuthService`.
84 +async fn whoami(
85 + axum::Extension(claims): axum::Extension<rustwarden_core::auth::jwt::Claims>,
86 +) -> Json<serde_json::Value> {
87 + Json(json!({ "sub": claims.sub, "username": claims.username }))
88 +}
M crates/rustwarden-core/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-core"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Core library for Rustwarden – authentication, RBAC, user management framework"
M crates/rustwarden-core/src/app.rs
+186 -23
@@ -369,11 +369,17 @@ impl RustwardenBuilder {
369 369
370 370 /// Run in headless mode (API-only, no UI).
371 371 ///
372 - /// Besides dropping the default UI routes, this flips the default
372 + /// Besides dropping the default UI routes, this flips the *default*
373 373 /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so
374 - /// protected routes answer unauthenticated requests with `401` JSON
375 - /// instead of redirecting to a `/login` page that does not exist. Call
376 - /// [`RustwardenBuilder::auth_failure_mode`] afterwards to override.
374 + /// protected prefixes, `/admin*` and `/api/v1/admin*` answer
375 + /// unauthenticated requests with `401` JSON instead of redirecting to a
376 + /// `/login` page that does not exist.
377 + ///
378 + /// It is only a default: an explicit
379 + /// [`RustwardenBuilder::auth_failure_mode`] call wins, and so does an
380 + /// explicit `AUTH_FAILURE_MODE` environment variable (RFC 0042). Since
381 + /// 1.2.0 the flip is applied by every enforcement point, including the
382 + /// prefix middlewares `AuthService` / `AdminService`.
377 383 ///
378 384 /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401
379 385 pub fn headless(mut self) -> Self {
@@ -384,6 +390,12 @@ impl RustwardenBuilder {
384 390
385 391 /// Override the browser login redirect path
386 392 /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`).
393 + ///
394 + /// Precedence: this setter, then the `LOGIN_PATH` env value, then `/login`.
395 + /// Honoured by the
396 + /// prefix middlewares (`AuthService`, `AdminService`), the extractors
397 + /// (`AuthUser`, `auth_required`, `admin_required`) and the UI root
398 + /// redirect (RFC 0042).
387 399 pub fn login_path(mut self, path: impl Into<String>) -> Self {
388 400 self.login_path = Some(path.into());
389 401 self
@@ -391,8 +403,12 @@ impl RustwardenBuilder {
391 403
392 404 /// Override how the auth middleware answers unauthenticated requests to
393 405 /// protected routes
394 - /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). Takes
395 - /// precedence over the default flipped by [`RustwardenBuilder::headless`].
406 + /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]).
407 + ///
408 + /// Precedence: this setter, then the `AUTH_FAILURE_MODE` env value, then
409 + /// the default flipped by [`RustwardenBuilder::headless`], then `Redirect`.
410 + /// Honoured by
411 + /// every enforcement point since 1.2.0 (RFC 0042).
396 412 pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self {
397 413 self.auth_failure_mode = Some(mode);
398 414 self
@@ -474,11 +490,62 @@ impl RustwardenBuilder {
474 490 self
475 491 }
476 492
493 + /// Apply the builder overrides on top of the environment-loaded config.
494 + ///
495 + /// Pure: the environment is never read here. `failure_mode_set_in_env`
496 + /// tells whether `AUTH_FAILURE_MODE` was explicitly set, so an operator
497 + /// value keeps precedence over the `headless()` default flip (RFC 0042).
498 + ///
499 + /// Precedence for `auth_failure_mode`: the `auth_failure_mode()` setter,
500 + /// then the `AUTH_FAILURE_MODE` env value, then the `headless()` flip to
501 + /// `Json401`, then `Redirect`.
502 + /// Precedence for `login_path`: the `login_path()` setter, then the
503 + /// `LOGIN_PATH` env value, then `/login`.
504 + /// Whether a *valid* `AUTH_FAILURE_MODE` value is present in the
505 + /// environment. Pure: takes the raw value so it can be unit-tested.
506 + ///
507 + /// Presence alone is not enough: `AuthConfig::from_env` silently falls
508 + /// back to `Redirect` when the value does not parse (empty string, typo),
509 + /// and that fallback must not suppress the `headless()` flip.
510 + fn failure_mode_overridden_by_env(raw: Option<&str>) -> bool {
511 + raw.and_then(|value| value.parse::<crate::config::auth::AuthFailureMode>().ok())
512 + .is_some()
513 + }
514 +
515 + fn apply_auth_overrides(
516 + &self,
517 + mut cfg: AuthConfig,
518 + failure_mode_set_in_env: bool,
519 + ) -> AuthConfig {
520 + if let Some(path) = &self.login_path {
521 + cfg.login_path = path.clone();
522 + }
523 + if let Some(mode) = self.auth_failure_mode {
524 + cfg.auth_failure_mode = mode;
525 + } else if self.headless && !failure_mode_set_in_env {
526 + cfg.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401;
527 + }
528 + cfg
529 + }
530 +
477 531 /// Build the application. Connects to the database, initializes services,
478 532 /// and assembles the router with all middleware.
479 - pub async fn build(mut self) -> anyhow::Result<RustwardenApp> {
533 + pub async fn build(self) -> anyhow::Result<RustwardenApp> {
480 534 tracing::info!("Building Rustwarden application...");
481 535
536 + // 0. Auth config (SEC-M1: load once, share via Extension; RFC 0042:
537 + // the resolved config is also handed to `AuthService` /
538 + // `AdminService` at construction so builder overrides reach them).
539 + // Resolved before the database is taken out of `self`, as the
540 + // overrides live on the builder.
541 + let failure_mode_set_in_env = Self::failure_mode_overridden_by_env(
542 + std::env::var("AUTH_FAILURE_MODE").ok().as_deref(),
543 + );
544 + let auth_config = self.apply_auth_overrides(
545 + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?,
546 + failure_mode_set_in_env,
547 + );
548 +
482 549 // 1. Database connection
483 550 let db: DatabaseConnection = if let Some(db) = self.db {
484 551 db
@@ -509,22 +576,12 @@ impl RustwardenBuilder {
509 576 AppSettingsService::initialize_default_settings(&db).await?;
510 577 tracing::info!("Default admin and app settings initialized");
511 578
512 - // 3. Auth config & session (SEC-M1: load once, share via Extension)
513 - let mut auth_config =
514 - AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?;
515 - // Apply builder overrides: an explicit setter wins; otherwise
516 - // `.headless()` flips the default failure mode to Json401 so API-only
517 - // apps return 401 instead of redirecting to a non-existent /login.
518 - if let Some(path) = self.login_path.take() {
519 - auth_config.login_path = path;
520 - }
521 - if let Some(mode) = self.auth_failure_mode {
522 - auth_config.auth_failure_mode = mode;
523 - } else if self.headless {
524 - auth_config.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401;
525 - }
579 + // 3. Session layer — built from the final config (after overrides,
580 + // step 0): it reads the cookie / timeout settings.
526 581 let session_layer = SessionConfig::create_memory_layer(&auth_config);
527 582 let auth_config = Arc::new(auth_config);
583 + let auth_config_for_admin = auth_config.clone();
584 + let auth_config_for_auth = auth_config.clone();
528 585 tracing::info!("Session management configured");
529 586
530 587 // 4. Email & background tasks
@@ -794,9 +851,20 @@ impl RustwardenBuilder {
794 851 async move { security_headers_middleware(cfg, req, next).await }
795 852 }
796 853 }))
797 - .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
798 854 .layer_fn(move |service| {
799 - AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone())
855 + AdminService::with_auth_config(
856 + service,
857 + db_for_admin.clone(),
858 + auth_config_for_admin.clone(),
859 + )
860 + })
861 + .layer_fn(move |service| {
862 + AuthService::with_auth_config(
863 + service,
864 + db_for_auth.clone(),
865 + prefixes_for_auth.clone(),
866 + auth_config_for_auth.clone(),
867 + )
800 868 })
801 869 .layer(session_layer)
802 870 .layer(axum::middleware::from_fn(move |req, next| {
@@ -1107,6 +1175,101 @@ mod builder_tests {
1107 1175 assert!(builder.protected_prefixes.iter().any(|p| p == "/custom"));
1108 1176 }
1109 1177
1178 + /// RFC 0042 — precedence of the auth overrides, exercised on the pure
1179 + /// `apply_auth_overrides` without touching the process environment.
1180 + mod apply_auth_overrides {
1181 + use super::*;
1182 + use crate::config::auth::{AuthFailureMode, SessionBackend};
1183 +
1184 + fn base_config() -> AuthConfig {
1185 + AuthConfig {
1186 + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
1187 + jwt_expiration_minutes: 15,
1188 + jwt_issuer: "test".to_string(),
1189 + refresh_token_expiration_days: 7,
1190 + remember_me_expiration_days: 30,
1191 + session_timeout_minutes: 30,
1192 + session_backend: SessionBackend::Memory,
1193 + redis_url: None,
1194 + rate_limit_login_per_minute: 5,
1195 + rate_limit_refresh_per_minute: 10,
1196 + rate_limit_general_per_minute: 100,
1197 + cookie_secure: false,
1198 + cookie_same_site: "Strict".to_string(),
1199 + login_path: "/login".to_string(),
1200 + auth_failure_mode: AuthFailureMode::Redirect,
1201 + }
1202 + }
1203 +
1204 + #[test]
1205 + fn headless_flips_default_to_json401() {
1206 + let cfg = RustwardenBuilder::default()
1207 + .headless()
1208 + .apply_auth_overrides(base_config(), false);
1209 + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401);
1210 + }
1211 +
1212 + #[test]
1213 + fn explicit_setter_wins_over_headless() {
1214 + let cfg = RustwardenBuilder::default()
1215 + .headless()
1216 + .auth_failure_mode(AuthFailureMode::Redirect)
1217 + .apply_auth_overrides(base_config(), false);
1218 + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
1219 + }
1220 +
1221 + #[test]
1222 + fn env_value_wins_over_headless_flip() {
1223 + // `base_config()` stands for what `from_env()` produced from an
1224 + // explicit `AUTH_FAILURE_MODE=redirect`.
1225 + let cfg = RustwardenBuilder::default()
1226 + .headless()
1227 + .apply_auth_overrides(base_config(), true);
1228 + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
1229 + }
1230 +
1231 + #[test]
1232 + fn explicit_setter_wins_over_env_value() {
1233 + let cfg = RustwardenBuilder::default()
1234 + .auth_failure_mode(AuthFailureMode::Json401)
1235 + .apply_auth_overrides(base_config(), true);
1236 + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401);
1237 + }
1238 +
1239 + #[test]
1240 + fn login_path_setter_is_propagated() {
1241 + let cfg = RustwardenBuilder::default()
1242 + .login_path("/auth/signin")
1243 + .apply_auth_overrides(base_config(), false);
1244 + assert_eq!(cfg.login_path, "/auth/signin");
1245 + }
1246 +
1247 + #[test]
1248 + fn non_headless_without_setter_is_unchanged() {
1249 + let cfg = RustwardenBuilder::default().apply_auth_overrides(base_config(), false);
1250 + assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
1251 + assert_eq!(cfg.login_path, "/login");
1252 + }
1253 +
1254 + /// Only a *parseable* env value counts as an operator override: an
1255 + /// empty or misspelled `AUTH_FAILURE_MODE` makes `from_env` fall back
1256 + /// to `Redirect`, and that must not suppress the `headless()` flip.
1257 + #[test]
1258 + fn env_signal_requires_a_valid_value() {
1259 + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(None));
1260 + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some("")));
1261 + assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some(
1262 + "json-401"
1263 + )));
1264 + assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some(
1265 + "json401"
1266 + )));
1267 + assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some(
1268 + "Redirect"
1269 + )));
1270 + }
1271 + }
1272 +
1110 1273 #[test]
1111 1274 fn default_builder_uses_default_security_headers() {
1112 1275 let builder = RustwardenBuilder::default();
M crates/rustwarden-core/src/middleware/admin_service.rs
+194 -26
@@ -10,23 +10,86 @@ use sea_orm::DatabaseConnection;
10 10 use std::{
11 11 future::Future,
12 12 pin::Pin,
13 + sync::Arc,
13 14 task::{Context, Poll},
14 15 };
15 16 use tower::Service;
16 17
17 18 use super::auth_service;
19 +use crate::config::auth::AuthConfig;
18 20
19 21 /// Service wrapper that applies admin_required middleware to /admin/* routes
22 +///
23 +/// The [`AuthConfig`] used to verify the JWT is resolved by
24 +/// `auth_service::resolve_auth_config`: the config captured at construction
25 +/// first, then `Extension(Arc<AuthConfig>)`, then
26 +/// [`AuthConfig::from_env`]. The last two steps are the legacy path of the
27 +/// deprecated [`AdminService::new`] constructor and ignore builder overrides
28 +/// whenever this middleware is layered outside the `Extension` layer.
20 29 #[derive(Clone)]
21 30 pub struct AdminService<S> {
22 31 inner: S,
23 32 db: DatabaseConnection,
33 + /// Config captured at construction. `None` only on the deprecated
34 + /// [`AdminService::new`] path.
35 + auth_config: Option<Arc<AuthConfig>>,
24 36 }
25 37
26 38 impl<S> AdminService<S> {
27 39 /// Create a new `AdminService` middleware wrapping the given inner service.
40 + #[deprecated(
41 + since = "1.2.0",
42 + note = "use `AdminService::with_auth_config(inner, db, auth_config)`. Removal no earlier than 1.5.0, effective in the next major."
43 + )]
28 44 pub fn new(inner: S, db: DatabaseConnection) -> Self {
29 - Self { inner, db }
45 + Self {
46 + inner,
47 + db,
48 + auth_config: None,
49 + }
50 + }
51 +
52 + /// Create an `AdminService` that carries its own resolved
53 + /// [`AuthConfig`] — the supported constructor since RFC 0042.
54 + ///
55 + /// The config is captured **once at construction**, the same pattern
56 + /// `security_headers_middleware` uses in
57 + /// [`crate::app::RustwardenBuilder::build`]. Consequences:
58 + ///
59 + /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`)
60 + /// are honoured regardless of tower layer order. A middleware layered
61 + /// *outside* `Extension(Arc<AuthConfig>)` can never observe that
62 + /// extension, which is what made [`AdminService::new`] fall back to
63 + /// [`AuthConfig::from_env`] and discard those overrides.
64 + /// - The captured config takes precedence over any
65 + /// `Extension(Arc<AuthConfig>)` present in the request.
66 + /// - On every request the middleware inserts the captured `Arc` into the
67 + /// request extensions, so downstream extractors (`AuthUser`,
68 + /// `auth_required`, `admin_required`) observe the same instance even in
69 + /// hand-composed tower stacks. In the canonical wiring the inner
70 + /// `Extension` layer re-inserts the very same `Arc`, a no-op.
71 + ///
72 + /// ```no_run
73 + /// # use std::sync::Arc;
74 + /// # use rustwarden_core::config::auth::AuthConfig;
75 + /// # use rustwarden_core::middleware::admin_service::AdminService;
76 + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
77 + /// let auth_config = Arc::new(AuthConfig::from_env()?);
78 + /// let app = router.layer(tower::layer::layer_fn(move |inner| {
79 + /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone())
80 + /// }));
81 + /// # let _ = app; Ok(()) }
82 + /// ```
83 + pub fn with_auth_config(
84 + inner: S,
85 + db: DatabaseConnection,
86 + auth_config: Arc<AuthConfig>,
87 + ) -> Self {
88 + Self {
89 + inner,
90 + db,
91 + auth_config: Some(auth_config),
92 + }
30 93 }
31 94 }
32 95
@@ -44,8 +107,19 @@ where
44 107 self.inner.poll_ready(cx)
45 108 }
46 109
47 - fn call(&mut self, req: Request<B>) -> Self::Future {
110 + fn call(&mut self, mut req: Request<B>) -> Self::Future {
48 111 let uri = req.uri().clone();
112 +
113 + // Seed the captured config into the request extensions so downstream
114 + // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve
115 + // the exact same instance. In the canonical wiring the later
116 + // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which
117 + // is a no-op; seeding only matters for hand-composed tower stacks.
118 + if let Some(config) = &self.auth_config {
119 + req.extensions_mut().insert(config.clone());
120 + }
121 + let captured = self.auth_config.clone();
122 +
49 123 let path = uri.path();
50 124
51 125 // Only apply admin_required middleware to /admin/* and /api/v1/admin/* routes
@@ -61,33 +135,25 @@ where
61 135 let (parts, body) = req.into_parts();
62 136
63 137 use crate::auth::jwt::JwtService;
64 - use crate::config::auth::AuthConfig;
65 138 use crate::services::jwt_blacklist_service::JwtBlacklistService;
66 139
67 140 // Resolve auth config up-front so every auth-failure branch
68 - // honours `login_path` / `auth_failure_mode`. Prefer the
69 - // injected `Extension`; fall back to `AuthConfig::from_env()`
70 - // when the layer order puts the Extension further inside the
71 - // stack (same regression as `AuthService` since `d4d6828`).
72 - let auth_config = match parts
73 - .extensions
74 - .get::<std::sync::Arc<AuthConfig>>()
75 - .cloned()
76 - {
77 - Some(config) => config,
78 - None => match AuthConfig::from_env() {
79 - Ok(cfg) => std::sync::Arc::new(cfg),
80 - Err(err) => {
81 - tracing::error!(
82 - "AdminService: AuthConfig unavailable (no extension and from_env failed): {err}"
83 - );
84 - return Ok((
85 - axum::http::StatusCode::INTERNAL_SERVER_ERROR,
86 - "Internal server error",
87 - )
88 - .into_response());
89 - }
90 - },
141 + // honours `login_path` / `auth_failure_mode`.
142 + let auth_config = match crate::middleware::auth_service::resolve_auth_config(
143 + captured.as_ref(),
144 + &parts.extensions,
145 + ) {
146 + Ok(config) => config,
147 + Err(err) => {
148 + tracing::error!(
149 + "AdminService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}"
150 + );
151 + return Ok((
152 + axum::http::StatusCode::INTERNAL_SERVER_ERROR,
153 + "Internal server error",
154 + )
155 + .into_response());
156 + }
91 157 };
92 158
93 159 // Extract JWT token from parts
@@ -152,3 +218,105 @@ where
152 218 }
153 219 }
154 220 }
221 +
222 +#[cfg(test)]
223 +mod tests {
224 + use super::AdminService;
225 + use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
226 + use axum::body::Body;
227 + use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION};
228 + use axum::response::Response;
229 + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
230 + use std::sync::Arc;
231 + use tower::ServiceExt; // for `oneshot`
232 +
233 + fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig {
234 + AuthConfig {
235 + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
236 + jwt_expiration_minutes: 15,
237 + jwt_issuer: "test".to_string(),
238 + refresh_token_expiration_days: 7,
239 + remember_me_expiration_days: 30,
240 + session_timeout_minutes: 30,
241 + session_backend: SessionBackend::Memory,
242 + redis_url: None,
243 + rate_limit_login_per_minute: 5,
244 + rate_limit_refresh_per_minute: 10,
245 + rate_limit_general_per_minute: 100,
246 + cookie_secure: false,
247 + cookie_same_site: "Strict".to_string(),
248 + login_path: login_path.to_string(),
249 + auth_failure_mode: mode,
250 + }
251 + }
252 +
253 + fn mock_db() -> DatabaseConnection {
254 + MockDatabase::new(DatabaseBackend::Postgres).into_connection()
255 + }
256 +
257 + /// Router wrapped with `AdminService::with_auth_config` and **no**
258 + /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only
259 + /// source of truth, exactly as RFC 0042 requires.
260 + fn admin_router(cfg: AuthConfig) -> axum::Router {
261 + let db = mock_db();
262 + let cfg = Arc::new(cfg);
263 + axum::Router::new()
264 + .route("/admin", axum::routing::get(|| async { "admin" }))
265 + .route(
266 + "/api/v1/admin/x",
267 + axum::routing::get(|| async { "admin api" }),
268 + )
269 + .layer(tower::layer::layer_fn(move |inner| {
270 + AdminService::with_auth_config(inner, db.clone(), cfg.clone())
271 + }))
272 + }
273 +
274 + async fn get(router: axum::Router, uri: &str) -> Response {
275 + let req = HttpRequest::builder()
276 + .uri(uri)
277 + .body(Body::empty())
278 + .expect("request");
279 + router.oneshot(req).await.expect("oneshot")
280 + }
281 +
282 + async fn body_string(resp: Response) -> String {
283 + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
284 + .await
285 + .expect("body");
286 + String::from_utf8(bytes.to_vec()).expect("utf-8 body")
287 + }
288 +
289 + /// T7 — both admin prefixes honour the captured `Json401` mode without
290 + /// any environment variable or `Extension` layer.
291 + #[tokio::test]
292 + async fn admin_routes_without_jwt_return_json_401_from_the_captured_config() {
293 + for uri in ["/admin", "/api/v1/admin/x"] {
294 + let resp = get(
295 + admin_router(test_config(AuthFailureMode::Json401, "/login")),
296 + uri,
297 + )
298 + .await;
299 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "uri = {uri}");
300 + assert!(resp.headers().get(LOCATION).is_none(), "uri = {uri}");
301 + let body = body_string(resp).await;
302 + assert!(body.contains("unauthorized"), "uri = {uri}, body = {body}");
303 + }
304 + }
305 +
306 + /// T7 — `Redirect` mode honours the captured `login_path` override that
307 + /// the deprecated `AdminService::new` path used to discard.
308 + #[tokio::test]
309 + async fn admin_route_redirect_mode_uses_the_captured_login_path() {
310 + let resp = get(
311 + admin_router(test_config(AuthFailureMode::Redirect, "/auth/signin")),
312 + "/admin",
313 + )
314 + .await;
315 +
316 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
317 + assert_eq!(
318 + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()),
319 + Some("/auth/signin?redirect=%2Fadmin")
320 + );
321 + }
322 +}
M crates/rustwarden-core/src/middleware/auth_service.rs
+366 -61
@@ -11,7 +11,7 @@ use sea_orm::DatabaseConnection;
11 11 use std::{
12 12 future::Future,
13 13 pin::Pin,
14 - sync::Arc,
14 + sync::{Arc, Once},
15 15 task::{Context, Poll},
16 16 };
17 17 use tower::Service;
@@ -137,19 +137,29 @@ pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> T
137 137 /// `Option<Extension<Claims>>`.
138 138 ///
139 139 /// Both branches resolve the [`crate::config::auth::AuthConfig`]
140 -/// required for JWT verification via the same two-step lookup:
140 +/// required for JWT verification via the same three-step lookup
141 +/// (RFC 0042):
141 142 ///
142 -/// 1. Read `Extension(Arc<AuthConfig>)` from the request extensions
143 -/// when a downstream layer has posed it (the canonical wiring
144 -/// performed by [`crate::app::RustwardenBuilder::build`]).
145 -/// 2. Otherwise fall back to
146 -/// [`crate::config::auth::AuthConfig::from_env`] so the
147 -/// middleware keeps working when the layer ordering puts the
148 -/// `Extension` layer further inside the stack. The protected
149 -/// branch was patched first (commit `d4d6828`, RFC 0022); the
150 -/// optional branch was aligned by RFC 0031.
143 +/// 1. The `Arc<AuthConfig>` captured at construction time by
144 +/// [`AuthService::with_auth_config`]. This is the authoritative
145 +/// source: it is immune to tower layer ordering, so builder
146 +/// overrides (`login_path`, `auth_failure_mode`, `headless`) are
147 +/// always honoured.
148 +/// 2. `Extension(Arc<AuthConfig>)` read from the request extensions
149 +/// when a downstream layer has posed it.
150 +/// 3. [`crate::config::auth::AuthConfig::from_env`] as a last resort,
151 +/// reported once per process through a `tracing::warn!`.
151 152 ///
152 -/// On the protected branch, a failed `from_env` fallback produces a
153 +/// Steps 2 and 3 are the **legacy path of the deprecated
154 +/// [`AuthService::new`] constructor**. They silently drop builder
155 +/// overrides whenever the `Extension` layer sits further inside the
156 +/// stack than this middleware — which is precisely what
157 +/// [`crate::app::RustwardenBuilder::build`] does, since both services
158 +/// are layered outside `Extension(Arc<AuthConfig>)`. The protected
159 +/// branch was patched first (commit `d4d6828`, RFC 0022); the optional
160 +/// branch was aligned by RFC 0031; RFC 0042 added step 1.
161 +///
162 +/// On the protected branch, a failed resolution produces a
153 163 /// `500 Internal Server Error` after a `tracing::error!`. On the
154 164 /// optional branch, the request simply traverses without claims —
155 165 /// matching the semantics of an unauthenticated visitor — and a
@@ -163,6 +173,10 @@ pub struct AuthService<S> {
163 173 inner: S,
164 174 db: DatabaseConnection,
165 175 protected_prefixes: Arc<Vec<String>>,
176 + /// Config captured at construction. `None` only on the deprecated
177 + /// [`AuthService::new`] path, where the request extension / environment
178 + /// fallback applies.
179 + auth_config: Option<Arc<AuthConfig>>,
166 180 }
167 181
168 182 impl<S> AuthService<S> {
@@ -175,15 +189,104 @@ impl<S> AuthService<S> {
175 189 /// such as `rustwarden-fediverse` register their own prefixes
176 190 /// (`/fediverse/consent`, `/fediverse/invite`) via the builder before
177 191 /// the app boots.
192 + #[deprecated(
193 + since = "1.2.0",
194 + note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; the per-request `AuthConfig::from_env()` fallback ignores builder overrides (`login_path`, `auth_failure_mode`, `headless`). Removal no earlier than 1.5.0, effective in the next major."
195 + )]
178 196 pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self {
179 197 Self {
180 198 inner,
181 199 db,
182 200 protected_prefixes,
201 + auth_config: None,
202 + }
203 + }
204 +
205 + /// Create an `AuthService` that carries its own resolved
206 + /// [`AuthConfig`] — the supported constructor since RFC 0042.
207 + ///
208 + /// The config is captured **once at construction**, the same pattern
209 + /// `security_headers_middleware` uses in
210 + /// [`crate::app::RustwardenBuilder::build`]. Consequences:
211 + ///
212 + /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`)
213 + /// are honoured regardless of tower layer order. A middleware layered
214 + /// *outside* `Extension(Arc<AuthConfig>)` can never observe that
215 + /// extension, which is what made [`AuthService::new`] fall back to
216 + /// [`AuthConfig::from_env`] and discard those overrides.
217 + /// - The captured config takes precedence over any
218 + /// `Extension(Arc<AuthConfig>)` present in the request.
219 + /// - On every request the middleware inserts the captured `Arc` into the
220 + /// request extensions, so downstream extractors (`AuthUser`,
221 + /// `auth_required`, `admin_required`) observe the same instance even in
222 + /// hand-composed tower stacks that have no `Extension` layer of their
223 + /// own. In the canonical wiring the inner `Extension` layer re-inserts
224 + /// the very same `Arc`, which is a no-op.
225 + ///
226 + /// `protected_prefixes` has the same meaning as in
227 + /// [`AuthService::new`].
228 + ///
229 + /// ```no_run
230 + /// # use std::sync::Arc;
231 + /// # use rustwarden_core::config::auth::AuthConfig;
232 + /// # use rustwarden_core::middleware::auth_service::AuthService;
233 + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
234 + /// let auth_config = Arc::new(AuthConfig::from_env()?);
235 + /// let prefixes = Arc::new(vec!["/api/private".to_string()]);
236 + /// let app = router.layer(tower::layer::layer_fn(move |inner| {
237 + /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone())
238 + /// }));
239 + /// # let _ = app; Ok(()) }
240 + /// ```
241 + pub fn with_auth_config(
242 + inner: S,
243 + db: DatabaseConnection,
244 + protected_prefixes: Arc<Vec<String>>,
245 + auth_config: Arc<AuthConfig>,
246 + ) -> Self {
247 + Self {
248 + inner,
249 + db,
250 + protected_prefixes,
251 + auth_config: Some(auth_config),
183 252 }
184 253 }
185 254 }
186 255
256 +/// Emitted at most once per process when the deprecated resolution path
257 +/// reaches [`AuthConfig::from_env`] (RFC 0042, OQ5).
258 +static DEPRECATED_CONFIG_PATH_WARNING: Once = Once::new();
259 +
260 +/// Resolve the [`AuthConfig`] a request must be authenticated against.
261 +///
262 +/// Authoritative config first; request extension then environment only on
263 +/// the deprecated path. A free function so it can be called from inside the
264 +/// `'static` boxed future returned by `call` (where `self` is unavailable).
265 +pub(crate) fn resolve_auth_config(
266 + captured: Option<&Arc<AuthConfig>>,
267 + extensions: &axum::http::Extensions,
268 +) -> Result<Arc<AuthConfig>, String> {
269 + if let Some(config) = captured {
270 + return Ok(config.clone());
271 + }
272 + if let Some(config) = extensions.get::<Arc<AuthConfig>>() {
273 + return Ok(config.clone());
274 + }
275 + let config = AuthConfig::from_env().map(Arc::new)?;
276 + // Warn only once the fallback actually succeeded, so the message never
277 + // precedes a `from_env failed` error for the same request.
278 + DEPRECATED_CONFIG_PATH_WARNING.call_once(|| {
279 + tracing::warn!(
280 + "AuthConfig resolved from the environment: the deprecated \
281 + `AuthService::new` / `AdminService::new` constructor path is in use, \
282 + so builder overrides (login_path, auth_failure_mode, headless) are \
283 + ignored. Switch to `with_auth_config(..)`. This warning is emitted \
284 + once per process."
285 + );
286 + });
287 + Ok(config)
288 +}
289 +
187 290 impl<S, B> Service<Request<B>> for AuthService<S>
188 291 where
189 292 S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
@@ -198,10 +301,20 @@ where
198 301 self.inner.poll_ready(cx)
199 302 }
200 303
201 - fn call(&mut self, req: Request<B>) -> Self::Future {
304 + fn call(&mut self, mut req: Request<B>) -> Self::Future {
202 305 let uri = req.uri().clone();
203 306 let path = uri.path().to_owned();
204 307
308 + // Seed the captured config into the request extensions so downstream
309 + // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve
310 + // the exact same instance. In the canonical wiring the later
311 + // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which
312 + // is a no-op; seeding only matters for hand-composed tower stacks.
313 + if let Some(config) = &self.auth_config {
314 + req.extensions_mut().insert(config.clone());
315 + }
316 + let captured = self.auth_config.clone();
317 +
205 318 // NOTE on `/api/v1/teams/*` : *not* protected here on purpose.
206 319 // The canonical admin path is the UI handler
207 320 // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite`
@@ -232,29 +345,18 @@ where
232 345
233 346 // Resolve AuthConfig up-front so every authentication-failure
234 347 // branch below can honour `login_path` / `auth_failure_mode`.
235 - // Prefer the injected `Extension`; fall back to
236 - // `AuthConfig::from_env()` when the layer order puts the
237 - // Extension layer further inside the stack — the regression
238 - // commit `d4d6828` flipped this path to a hard 500 by mistake.
239 - let auth_config = match parts
240 - .extensions
241 - .get::<std::sync::Arc<AuthConfig>>()
242 - .cloned()
243 - {
244 - Some(config) => config,
245 - None => match AuthConfig::from_env() {
246 - Ok(cfg) => std::sync::Arc::new(cfg),
247 - Err(err) => {
248 - tracing::error!(
249 - "AuthService: AuthConfig unavailable (no extension and from_env failed): {err}"
250 - );
251 - return Ok((
252 - axum::http::StatusCode::INTERNAL_SERVER_ERROR,
253 - "Internal server error",
254 - )
255 - .into_response());
256 - }
257 - },
348 + let auth_config = match resolve_auth_config(captured.as_ref(), &parts.extensions) {
349 + Ok(config) => config,
350 + Err(err) => {
351 + tracing::error!(
352 + "AuthService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}"
353 + );
354 + return Ok((
355 + axum::http::StatusCode::INTERNAL_SERVER_ERROR,
356 + "Internal server error",
357 + )
358 + .into_response());
359 + }
258 360 };
259 361
260 362 let jwt_token =
@@ -453,7 +555,6 @@ where
453 555 crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts)
454 556 {
455 557 use crate::auth::jwt::JwtService;
456 - use crate::config::auth::AuthConfig;
457 558 use crate::services::jwt_blacklist_service::JwtBlacklistService;
458 559
459 560 let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token)
@@ -461,27 +562,22 @@ where
461 562 .unwrap_or(true);
462 563
463 564 if !is_blacklisted {
464 - // RFC 0031: mirror the protected branch fallback so
465 - // routes outside `protected_prefixes` still see
466 - // injected claims when the `Extension(Arc<AuthConfig>)`
467 - // layer runs *after* `AuthService` in the tower
468 - // ordering. Without this fallback, `claims` was
469 - // silently `None` for all optional-auth routes.
470 - let auth_config = parts
471 - .extensions
472 - .get::<std::sync::Arc<AuthConfig>>()
473 - .cloned()
474 - .or_else(|| {
475 - AuthConfig::from_env()
476 - .map(std::sync::Arc::new)
477 - .map_err(|e| {
478 - tracing::error!(
479 - "AuthService optional_auth: AuthConfig fallback failed: {e}"
480 - );
481 - e
482 - })
483 - .ok()
484 - });
565 + // RFC 0031 / RFC 0042: mirror the protected branch
566 + // resolution so routes outside `protected_prefixes`
567 + // still see injected claims, whatever the tower
568 + // ordering of the `Extension(Arc<AuthConfig>)` layer.
569 + // Without it, `claims` was silently `None` for all
570 + // optional-auth routes.
571 + let auth_config =
572 + match resolve_auth_config(captured.as_ref(), &parts.extensions) {
573 + Ok(config) => Some(config),
574 + Err(e) => {
575 + tracing::error!(
576 + "AuthService optional_auth: AuthConfig fallback failed: {e}"
577 + );
578 + None
579 + }
580 + };
485 581
486 582 if let Some(auth_config) = auth_config {
487 583 if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token)
@@ -505,11 +601,220 @@ where
505 601 #[cfg(test)]
506 602 mod tests {
507 603 use super::{
508 - TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect,
509 - totp_force_gate,
604 + AuthService, TotpGate, UserLookup, auth_failure_response, map_user_lookup,
605 + sanitize_redirect, totp_force_gate,
510 606 };
511 607 use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
512 - use axum::http::{StatusCode, Uri, header::LOCATION};
608 + use axum::body::Body;
609 + use axum::http::{Request as HttpRequest, StatusCode, Uri, header::LOCATION};
610 + use axum::response::Response;
611 + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
612 + use std::collections::BTreeMap;
613 + use std::sync::Arc;
614 + use tower::ServiceExt; // for `oneshot`
615 +
616 + /// Mock connection that answers no query: any DB access is a test failure
617 + /// (its transaction log is asserted empty by the "no token" tests).
618 + fn mock_db() -> DatabaseConnection {
619 + MockDatabase::new(DatabaseBackend::Postgres).into_connection()
620 + }
621 +
622 + /// Row shape SeaORM 2.0 expects for `Select::count()`:
623 + /// `SELECT COUNT(*) AS num_items FROM (..) AS sub_query`.
624 + fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> {
625 + BTreeMap::from([("num_items", Value::BigInt(Some(n)))])
626 + }
627 +
628 + /// Mock connection whose single query result is the JWT-blacklist COUNT.
629 + fn mock_db_with_blacklist_count(n: i64) -> DatabaseConnection {
630 + MockDatabase::new(DatabaseBackend::Postgres)
631 + .append_query_results([vec![blacklist_count(n)]])
632 + .into_connection()
633 + }
634 +
635 + /// Number of SQL statements the mock connection actually received.
636 + fn statement_count(db: DatabaseConnection) -> usize {
637 + db.into_transaction_log()
638 + .iter()
639 + .map(|txn| txn.statements().len())
640 + .sum()
641 + }
642 +
643 + /// Router wrapped with `AuthService::with_auth_config` and **no**
644 + /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only
645 + /// source of truth, exactly as RFC 0042 requires.
646 + fn auth_router(db: DatabaseConnection, prefixes: &[&str], cfg: AuthConfig) -> axum::Router {
647 + let prefixes = Arc::new(
648 + prefixes
649 + .iter()
650 + .map(|p| (*p).to_string())
651 + .collect::<Vec<_>>(),
652 + );
653 + let cfg = Arc::new(cfg);
654 + axum::Router::new()
655 + .route("/private", axum::routing::get(|| async { "private" }))
656 + .route(
657 + "/public",
658 + axum::routing::get(
659 + |axum::Extension(cfg): axum::Extension<Arc<AuthConfig>>| async move {
660 + cfg.login_path.clone()
661 + },
662 + ),
663 + )
664 + .layer(tower::layer::layer_fn(move |inner| {
665 + AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), cfg.clone())
666 + }))
667 + }
668 +
669 + async fn send(router: axum::Router, uri: &str, bearer: Option<&str>) -> Response {
670 + let mut builder = HttpRequest::builder().uri(uri);
671 + if let Some(token) = bearer {
672 + builder = builder.header("authorization", format!("Bearer {token}"));
673 + }
674 + let req = builder.body(Body::empty()).expect("request");
675 + router.oneshot(req).await.expect("oneshot")
676 + }
677 +
678 + async fn body_string(resp: Response) -> String {
679 + let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
680 + .await
681 + .expect("body");
682 + String::from_utf8(bytes.to_vec()).expect("utf-8 body")
683 + }
684 +
685 + fn location(resp: &Response) -> Option<&str> {
686 + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok())
687 + }
688 +
689 + /// T1 — protected prefix, no credentials, `Json401`: the captured config
690 + /// drives the 401 JSON response and no DB round-trip happens at all.
691 + #[tokio::test]
692 + async fn protected_route_without_jwt_returns_json_401_without_touching_the_database() {
693 + let db = mock_db();
694 + let router = auth_router(
695 + db.clone(),
696 + &["/private"],
697 + test_config(AuthFailureMode::Json401, "/login"),
698 + );
699 +
700 + let resp = send(router, "/private", None).await;
701 +
702 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
703 + assert!(location(&resp).is_none());
704 + let body = body_string(resp).await;
705 + assert!(body.contains("unauthorized"), "unexpected body: {body}");
706 + assert_eq!(
707 + statement_count(db),
708 + 0,
709 + "a missing token must short-circuit before any query"
710 + );
711 + }
712 +
713 + /// T2 — protected prefix, no credentials, `Redirect`: the captured
714 + /// `login_path` override is honoured even though no `Extension` layer
715 + /// exists and no environment variable is set.
716 + #[tokio::test]
717 + async fn protected_route_redirect_mode_uses_the_captured_login_path() {
718 + let router = auth_router(
719 + mock_db(),
720 + &["/private"],
721 + test_config(AuthFailureMode::Redirect, "/auth/signin"),
722 + );
723 +
724 + let resp = send(router, "/private", None).await;
725 +
726 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
727 + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate"));
728 + }
729 +
730 + /// T3 — the captured config is seeded into the request extensions, so a
731 + /// handler on a non-protected route can extract it although the test
732 + /// stack declares no `Extension` layer at all.
733 + #[tokio::test]
734 + async fn captured_config_is_seeded_into_the_request_extensions() {
735 + let router = auth_router(
736 + mock_db(),
737 + &["/private"],
738 + test_config(AuthFailureMode::Json401, "/auth/signin"),
739 + );
740 +
741 + let resp = send(router, "/public", None).await;
742 +
743 + assert_eq!(resp.status(), StatusCode::OK);
744 + assert_eq!(body_string(resp).await, "/auth/signin");
745 + }
746 +
747 + /// T4 — a JWT signed with a foreign secret must be rejected by
748 + /// `JwtService::validate_token` against the *captured* config. The
749 + /// blacklist COUNT is mocked to 0 so the flow actually reaches the
750 + /// signature check, and it is the only statement the DB sees.
751 + #[tokio::test]
752 + async fn protected_route_rejects_jwt_signed_with_another_secret() {
753 + use crate::auth::jwt::JwtService;
754 +
755 + let cfg = test_config(AuthFailureMode::Json401, "/login");
756 + let mut foreign_cfg = test_config(AuthFailureMode::Json401, "/login");
757 + foreign_cfg.jwt_secret = "fedcba9876543210fedcba9876543210".to_string();
758 + let foreign_token = JwtService::generate_token(
759 + &foreign_cfg,
760 + uuid::Uuid::new_v4(),
761 + "intruder",
762 + Vec::new(),
763 + Vec::new(),
764 + )
765 + .expect("foreign token");
766 + assert!(
767 + JwtService::validate_token(&cfg, &foreign_token).is_err(),
768 + "the token must not verify against the captured config"
769 + );
770 +
771 + let db = mock_db_with_blacklist_count(0);
772 + let router = auth_router(db.clone(), &["/private"], cfg);
773 +
774 + let resp = send(router, "/private", Some(&foreign_token)).await;
775 +
776 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
777 + assert_eq!(
778 + statement_count(db),
779 + 1,
780 + "only the JWT-blacklist COUNT should reach the database"
781 + );
782 + }
783 +
784 + /// T5 — a JWT that *is* valid for the captured config is still refused
785 + /// when the blacklist COUNT reports it as revoked.
786 + #[tokio::test]
787 + async fn protected_route_rejects_blacklisted_but_valid_jwt() {
788 + use crate::auth::jwt::JwtService;
789 +
790 + let cfg = test_config(AuthFailureMode::Json401, "/login");
791 + let token = JwtService::generate_token(
792 + &cfg,
793 + uuid::Uuid::new_v4(),
794 + "alice",
795 + vec!["user".to_string()],
796 + Vec::new(),
797 + )
798 + .expect("token");
799 + assert!(
800 + JwtService::validate_token(&cfg, &token).is_ok(),
801 + "the token must verify against the captured config"
802 + );
803 +
804 + let db = mock_db_with_blacklist_count(1);
805 + let router = auth_router(db.clone(), &["/private"], cfg);
806 +
807 + let resp = send(router, "/private", Some(&token)).await;
808 +
809 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
810 + let body = body_string(resp).await;
811 + assert!(body.contains("unauthorized"), "unexpected body: {body}");
812 + assert_eq!(
813 + statement_count(db),
814 + 1,
815 + "the blacklist COUNT must short-circuit before the user lookup"
816 + );
817 + }
513 818
514 819 fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig {
515 820 AuthConfig {
A crates/rustwarden-core/tests/auth_service_resolved_config.rs
+237 -0
@@ -0,0 +1,237 @@
1 +//! RFC 0042 — `AuthService` / `AdminService` carry the `AuthConfig` resolved
2 +//! at construction (`with_auth_config`), so builder overrides are honoured
3 +//! and the per-request `AuthConfig::from_env()` is gone from the hot path.
4 +//!
5 +//! This file is a **separate test binary** on purpose: several `mod tests`
6 +//! of the library binary call `dotenvy::dotenv()` and load the repository
7 +//! `.env` (which contains `JWT_SECRET`) into the process. Here, with
8 +//! `#[serial_test::serial(env)]` serialising every test that mutates the
9 +//! environment (same intent as the mutex of
10 +//! `middleware_authconfig_fallback.rs`, without holding a `MutexGuard`
11 +//! across `.await`), we can really remove `JWT_SECRET` and prove that:
12 +//!
13 +//! - `with_auth_config` answers `401` without any environment at all;
14 +//! - the deprecated `new` path still answers `500` in the same situation
15 +//! (historical behaviour, RFC 0022), and still honours an
16 +//! `Extension(Arc<AuthConfig>)` posed *outside* the middleware.
17 +//!
18 +//! Plus an `#[ignore]`d micro-bench (RFC 0042 testing plan #16).
19 +
20 +#![allow(deprecated)]
21 +
22 +use std::sync::Arc;
23 +use std::time::Instant;
24 +
25 +use axum::body::Body;
26 +use axum::http::{Request, StatusCode, header::LOCATION};
27 +use axum::response::Response;
28 +use axum::{Extension, Router, routing::get};
29 +use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
30 +use rustwarden_core::middleware::admin_service::AdminService;
31 +use rustwarden_core::middleware::auth_service::AuthService;
32 +use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
33 +use tower::ServiceExt; // for `oneshot`
34 +
35 +fn set_minimal_env() {
36 + // SAFETY: every caller is a `#[serial_test::serial(env)]` test, so no
37 + // other thread reads or writes the environment concurrently.
38 + unsafe {
39 + std::env::set_var(
40 + "JWT_SECRET",
41 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
42 + );
43 + std::env::set_var("APP_DEBUG", "true");
44 + std::env::remove_var("AUTH_FAILURE_MODE");
45 + std::env::remove_var("LOGIN_PATH");
46 + }
47 +}
48 +
49 +fn clear_env() {
50 + // SAFETY: see set_minimal_env.
51 + unsafe {
52 + std::env::remove_var("JWT_SECRET");
53 + std::env::remove_var("APP_DEBUG");
54 + std::env::remove_var("AUTH_FAILURE_MODE");
55 + std::env::remove_var("LOGIN_PATH");
56 + }
57 +}
58 +
59 +fn injected_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig {
60 + AuthConfig {
61 + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
62 + jwt_expiration_minutes: 15,
63 + jwt_issuer: "test".to_string(),
64 + refresh_token_expiration_days: 7,
65 + remember_me_expiration_days: 30,
66 + session_timeout_minutes: 30,
67 + session_backend: SessionBackend::Memory,
68 + redis_url: None,
69 + rate_limit_login_per_minute: 5,
70 + rate_limit_refresh_per_minute: 10,
71 + rate_limit_general_per_minute: 100,
72 + cookie_secure: false,
73 + cookie_same_site: "Strict".to_string(),
74 + login_path: login_path.to_string(),
75 + auth_failure_mode: mode,
76 + }
77 +}
78 +
79 +fn mock_db() -> DatabaseConnection {
80 + MockDatabase::new(DatabaseBackend::Postgres).into_connection()
81 +}
82 +
83 +fn prefixes() -> Arc<Vec<String>> {
84 + Arc::new(vec!["/private".to_string()])
85 +}
86 +
87 +fn routes() -> Router {
88 + Router::new()
89 + .route("/private", get(|| async { "secret" }))
90 + .route("/admin", get(|| async { "admin" }))
91 +}
92 +
93 +/// Canonical 1.2.0 wiring: both middlewares carry the resolved config.
94 +fn stack_with_auth_config(cfg: AuthConfig) -> Router {
95 + let cfg = Arc::new(cfg);
96 + let (db_admin, cfg_admin) = (mock_db(), cfg.clone());
97 + let (db_auth, cfg_auth, p) = (mock_db(), cfg, prefixes());
98 + routes()
99 + .layer(tower::layer::layer_fn(move |inner| {
100 + AuthService::with_auth_config(inner, db_auth.clone(), p.clone(), cfg_auth.clone())
101 + }))
102 + .layer(tower::layer::layer_fn(move |inner| {
103 + AdminService::with_auth_config(inner, db_admin.clone(), cfg_admin.clone())
104 + }))
105 +}
106 +
107 +/// Legacy wiring (deprecated `new`): no captured config.
108 +fn stack_legacy() -> Router {
109 + let (db_admin, db_auth, p) = (mock_db(), mock_db(), prefixes());
110 + routes()
111 + .layer(tower::layer::layer_fn(move |inner| {
112 + AuthService::new(inner, db_auth.clone(), p.clone())
113 + }))
114 + .layer(tower::layer::layer_fn(move |inner| {
115 + AdminService::new(inner, db_admin.clone())
116 + }))
117 +}
118 +
119 +async fn get_path(router: Router, path: &str) -> Response {
120 + let req = Request::builder()
121 + .uri(path)
122 + .body(Body::empty())
123 + .expect("request");
124 + router.oneshot(req).await.expect("oneshot")
125 +}
126 +
127 +fn location(resp: &Response) -> Option<&str> {
128 + resp.headers().get(LOCATION).and_then(|v| v.to_str().ok())
129 +}
130 +
131 +#[tokio::test]
132 +#[serial_test::serial(env)]
133 +async fn with_auth_config_answers_without_any_environment() {
134 + clear_env();
135 + assert!(
136 + AuthConfig::from_env().is_err(),
137 + "precondition: no JWT_SECRET in the environment"
138 + );
139 +
140 + let cfg = injected_config(AuthFailureMode::Json401, "/login");
141 + let resp = get_path(stack_with_auth_config(cfg), "/private").await;
142 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
143 +
144 + let cfg = injected_config(AuthFailureMode::Json401, "/login");
145 + let resp = get_path(stack_with_auth_config(cfg), "/admin").await;
146 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
147 +}
148 +
149 +#[tokio::test]
150 +#[serial_test::serial(env)]
151 +async fn with_auth_config_honours_a_custom_login_path_without_environment() {
152 + clear_env();
153 +
154 + let cfg = injected_config(AuthFailureMode::Redirect, "/auth/signin");
155 + let resp = get_path(stack_with_auth_config(cfg), "/private").await;
156 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
157 + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate"));
158 +}
159 +
160 +#[tokio::test]
161 +#[serial_test::serial(env)]
162 +async fn legacy_new_without_extension_nor_environment_answers_500() {
163 + clear_env();
164 +
165 + let resp = get_path(stack_legacy(), "/private").await;
166 + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
167 +
168 + let resp = get_path(stack_legacy(), "/admin").await;
169 + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
170 +}
171 +
172 +#[tokio::test]
173 +#[serial_test::serial(env)]
174 +async fn legacy_new_uses_an_extension_posed_outside_the_middleware() {
175 + clear_env();
176 +
177 + // `Router::layer` after the middlewares = outermost = visible to them.
178 + let cfg = Arc::new(injected_config(AuthFailureMode::Redirect, "/auth/signin"));
179 + let resp = get_path(stack_legacy().layer(Extension(cfg)), "/private").await;
180 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
181 + assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate"));
182 +}
183 +
184 +#[tokio::test]
185 +#[serial_test::serial(env)]
186 +async fn legacy_new_falls_back_to_the_environment_and_ignores_overrides() {
187 + set_minimal_env();
188 +
189 + // No extension, env has no AUTH_FAILURE_MODE → from_env() default: Redirect to /login.
190 + let resp = get_path(stack_legacy(), "/private").await;
191 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
192 + assert_eq!(location(&resp), Some("/login?redirect=%2Fprivate"));
193 +
194 + clear_env();
195 +}
196 +
197 +/// RFC 0042 testing plan #16 — micro-bench, run with
198 +/// `cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench`.
199 +#[tokio::test]
200 +#[serial_test::serial(env)]
201 +#[ignore = "micro-bench, run explicitly with --ignored --nocapture"]
202 +async fn bench_from_env_versus_captured_config() {
203 + set_minimal_env();
204 + const N: u32 = 10_000;
205 +
206 + let start = Instant::now();
207 + for _ in 0..N {
208 + let _ = AuthConfig::from_env();
209 + }
210 + let from_env = start.elapsed();
211 +
212 + let start = Instant::now();
213 + for _ in 0..N {
214 + let resp = get_path(stack_legacy(), "/private").await;
215 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
216 + }
217 + let legacy = start.elapsed();
218 +
219 + let start = Instant::now();
220 + for _ in 0..N {
221 + let cfg = injected_config(AuthFailureMode::Redirect, "/login");
222 + let resp = get_path(stack_with_auth_config(cfg), "/private").await;
223 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
224 + }
225 + let captured = start.elapsed();
226 +
227 + println!(
228 + "bench RFC 0042 (N={N}): from_env alone = {:?} ({:?}/call); legacy stack = {:?} ({:?}/req); with_auth_config stack = {:?} ({:?}/req)",
229 + from_env,
230 + from_env / N,
231 + legacy,
232 + legacy / N,
233 + captured,
234 + captured / N,
235 + );
236 + clear_env();
237 +}
M crates/rustwarden-fediverse-guard/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse-guard"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Real-time abuse detection and ban management for the Rustwarden ActivityPub federation layer (RFC 0028+)"
M crates/rustwarden-fediverse-ui/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse-ui"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card"
M crates/rustwarden-fediverse/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "ActivityPub federation for Rustwarden — WebFinger, actor, inbox, HTTP signatures, AP-native auth, cross-instance team invitations"
M crates/rustwarden-ui/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-ui"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Default UI for Rustwarden – HTML templates, handlers, admin panel"
M crates/rustwarden-ui/src/handlers/root_handler.rs
+127 -29
@@ -2,77 +2,175 @@
2 2 /// Feature 004: UI Post-Auth
3 3 ///
4 4 /// Handles the root path `/` with smart redirection based on authentication status.
5 +use std::sync::Arc;
6 +
5 7 use axum::{
6 8 extract::Request,
7 9 response::{IntoResponse, Redirect},
8 10 };
11 +use rustwarden_core::config::auth::AuthConfig;
9 12
10 13 /// Handler for GET `/` - Root path with smart redirection
11 14 ///
12 15 /// Redirects based on authentication status:
13 16 /// - If authenticated → redirects to `/home`
14 -/// - If not authenticated → redirects to `/login`
17 +/// - If not authenticated → redirects to the configured login path
18 +/// ([`AuthConfig::login_path`], default `/login`)
15 19 ///
16 20 /// This provides a seamless entry point for users.
17 21 ///
22 +/// The [`AuthConfig`] is read from the request extensions, where
23 +/// `RustwardenBuilder::build()` injects the configuration resolved at
24 +/// startup (including builder overrides such as `login_path`, RFC 0042).
25 +/// `AuthConfig::from_env()` is only a defence for stacks that do not pose
26 +/// the extension.
27 +///
18 28 /// # Arguments
19 29 ///
20 30 /// * `request` - HTTP request to check for JWT token
21 31 ///
22 32 /// # Returns
23 33 ///
24 -/// * `302 Found` redirect to `/home` or `/login`
34 +/// * `303 See Other` redirect to `/home` or to the login path
25 35 ///
26 36 /// # Example
27 37 ///
28 38 /// ```bash
29 39 /// # Without authentication
30 40 /// curl -I http://localhost:3000/
31 -/// # → 302 Redirect to /login
41 +/// # → 303 Redirect to /login
32 42 ///
33 43 /// # With valid JWT token
34 44 /// curl -I http://localhost:3000/ -H "Cookie: jwt_token=..."
35 -/// # → 302 Redirect to /home
45 +/// # → 303 Redirect to /home
36 46 /// ```
37 47 pub async fn root_redirect(request: Request) -> impl IntoResponse {
48 + use rustwarden_core::auth::jwt::JwtService;
49 +
50 + // Prefer the config resolved at build time; keep `from_env()` as a
51 + // defence for stacks that do not pose the extension.
52 + let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() {
53 + Some(config) => config,
54 + None => match AuthConfig::from_env() {
55 + Ok(config) => Arc::new(config),
56 + Err(_) => {
57 + // If config can't be loaded, redirect to login as fallback
58 + tracing::warn!("root_redirect: failed to load auth config, redirecting to /login");
59 + return Redirect::to("/login");
60 + }
61 + },
62 + };
63 + let login_path = auth_config.login_path.as_str();
64 +
38 65 // Try to extract JWT token from request
39 66 match rustwarden_core::auth::jwt_extractor::extract_jwt_from_request(&request) {
40 67 Ok(jwt_token) => {
41 - // Token found, validate it
42 - use rustwarden_core::auth::jwt::JwtService;
43 - use rustwarden_core::config::auth::AuthConfig;
44 -
45 - // Load auth config and validate token
46 - // Note: We validate the token without checking blacklist for now
47 - // The token will be re-validated on protected routes anyway
48 - let auth_config = match AuthConfig::from_env() {
49 - Ok(config) => config,
50 - Err(_) => {
51 - // If config can't be loaded, redirect to login as fallback
52 - tracing::warn!(
53 - "root_redirect: failed to load auth config, redirecting to /login"
54 - );
55 - return Redirect::to("/login");
56 - }
57 - };
58 -
68 + // Validate the token without checking the blacklist: it is
69 + // re-validated on protected routes anyway.
59 70 match JwtService::validate_token(&auth_config, &jwt_token) {
60 71 Ok(_claims) => {
61 - // Token is valid, redirect to home
62 72 tracing::debug!("root_redirect: valid JWT token found, redirecting to /home");
63 73 Redirect::to("/home")
64 74 }
65 75 Err(_) => {
66 - // Token is invalid, redirect to login
67 - tracing::debug!("root_redirect: invalid JWT token, redirecting to /login");
68 - Redirect::to("/login")
76 + tracing::debug!(
77 + "root_redirect: invalid JWT token, redirecting to {login_path}"
78 + );
79 + Redirect::to(login_path)
69 80 }
70 81 }
71 82 }
72 83 Err(_) => {
73 - // No token found, redirect to login
74 - tracing::debug!("root_redirect: no JWT token found, redirecting to /login");
75 - Redirect::to("/login")
84 + tracing::debug!("root_redirect: no JWT token found, redirecting to {login_path}");
85 + Redirect::to(login_path)
86 + }
87 + }
88 +}
89 +
90 +#[cfg(test)]
91 +mod tests {
92 + use super::root_redirect;
93 + use axum::body::Body;
94 + use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION};
95 + use axum::{Extension, Router, routing::get};
96 + use rustwarden_core::auth::jwt::JwtService;
97 + use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
98 + use std::sync::Arc;
99 + use tower::ServiceExt; // for `oneshot`
100 +
101 + /// A `login_path` that cannot come from the environment of the test
102 + /// process, so a redirect to it proves the extension was used.
103 + const INJECTED_LOGIN_PATH: &str = "/auth/signin";
104 +
105 + fn injected_config() -> AuthConfig {
106 + AuthConfig {
107 + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
108 + jwt_expiration_minutes: 15,
109 + jwt_issuer: "test".to_string(),
110 + refresh_token_expiration_days: 7,
111 + remember_me_expiration_days: 30,
112 + session_timeout_minutes: 30,
113 + session_backend: SessionBackend::Memory,
114 + redis_url: None,
115 + rate_limit_login_per_minute: 5,
116 + rate_limit_refresh_per_minute: 10,
117 + rate_limit_general_per_minute: 100,
118 + cookie_secure: false,
119 + cookie_same_site: "Strict".to_string(),
120 + login_path: INJECTED_LOGIN_PATH.to_string(),
121 + auth_failure_mode: AuthFailureMode::Redirect,
122 + }
123 + }
124 +
125 + fn router_with_injected_config() -> Router {
126 + Router::new()
127 + .route("/", get(root_redirect))
128 + .layer(Extension(Arc::new(injected_config())))
129 + }
130 +
131 + async fn location_of(router: Router, bearer: Option<&str>) -> (StatusCode, String) {
132 + let mut builder = HttpRequest::builder().uri("/");
133 + if let Some(token) = bearer {
134 + builder = builder.header("authorization", format!("Bearer {token}"));
76 135 }
136 + let req = builder.body(Body::empty()).expect("request");
137 + let resp = router.oneshot(req).await.expect("oneshot");
138 + let location = resp
139 + .headers()
140 + .get(LOCATION)
141 + .and_then(|v| v.to_str().ok())
142 + .unwrap_or_default()
143 + .to_string();
144 + (resp.status(), location)
145 + }
146 +
147 + #[tokio::test]
148 + async fn anonymous_visitor_is_sent_to_the_injected_login_path() {
149 + let (status, location) = location_of(router_with_injected_config(), None).await;
150 + assert_eq!(status, StatusCode::SEE_OTHER);
151 + assert_eq!(location, INJECTED_LOGIN_PATH);
152 + }
153 +
154 + #[tokio::test]
155 + async fn invalid_token_is_sent_to_the_injected_login_path() {
156 + let (status, location) =
157 + location_of(router_with_injected_config(), Some("not-a-jwt")).await;
158 + assert_eq!(status, StatusCode::SEE_OTHER);
159 + assert_eq!(location, INJECTED_LOGIN_PATH);
160 + }
161 +
162 + #[tokio::test]
163 + async fn valid_token_signed_with_the_injected_secret_goes_home() {
164 + let token = JwtService::generate_token(
165 + &injected_config(),
166 + uuid::Uuid::new_v4(),
167 + "alice",
168 + vec!["user".to_string()],
169 + Vec::new(),
170 + )
171 + .expect("token");
172 + let (status, location) = location_of(router_with_injected_config(), Some(&token)).await;
173 + assert_eq!(status, StatusCode::SEE_OTHER);
174 + assert_eq!(location, "/home");
77 175 }
78 176 }
M docs/SERVICES_REFERENCE.md
+5 -1
@@ -1332,7 +1332,11 @@ L'auth middleware ne protège que les **préfixes enregistrés**. **Depuis 2026-
1332 1332
1333 1333 Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthConfig.auth_failure_mode` :
1334 1334 - `Redirect` (défaut UI) : `303` vers `login_path` (défaut `/login`, env `LOGIN_PATH` ou builder `.login_path("/…")`).
1335 -- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`** ; override explicite via `.auth_failure_mode(AuthFailureMode::…)` (env `AUTH_FAILURE_MODE=redirect|json401`).
1335 +- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`**.
1336 +
1337 +Précédence (RFC 0042, 1.2.0) : `.auth_failure_mode(AuthFailureMode::…)` > env `AUTH_FAILURE_MODE=redirect|json401` > bascule `.headless()` > `Redirect`. Même logique pour `.login_path(..)` > `LOGIN_PATH` > `/login`. Points d'application : préfixes protégés (`AuthService`), `/admin*` et `/api/v1/admin*` (`AdminService`), extracteurs `AuthUser` / `auth_required` / `admin_required`, et `root_redirect` de `rustwarden-ui`.
1338 +
1339 +> ⚠ **Avant 1.2.0**, `AuthService` et `AdminService` ignoraient ces overrides (ils relisaient `AuthConfig::from_env()` à chaque requête, cf. RFC 0042) : une app headless sans `AUTH_FAILURE_MODE` répondait `303 → /login`. Depuis 1.2.0 elle répond `401`. Si vous composez votre pile tower à la main, utilisez `AuthService::with_auth_config(inner, db, prefixes, auth_config)` et `AdminService::with_auth_config(inner, db, auth_config)` ; les constructeurs `new` sont dépréciés. Détail : [`auth-middleware-layering.md`](auth-middleware-layering.md).
1336 1340
1337 1341 #### Router auth JSON prêt à monter
1338 1342
M docs/UPSTREAM_RUSTWARDEN.md
+125 -592
@@ -1,592 +1,125 @@
1 -# Notes sur l'utilisation de RustWarden-core dans Gitrust
2 -
3 -Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core.
4 -
5 -## Services framework utilises
6 -
7 -| Service | Usage dans Gitrust |
8 -|---------|-------------------|
9 -| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` |
10 -| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` |
11 -| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` |
12 -| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` |
13 -| `AuthService` | `authenticate_user` (login) |
14 -| `JwtService` | `generate_token` (login) |
15 -| `EmailValidationService` | Verification email |
16 -| `PasswordResetService` | Flux "mot de passe oublie" |
17 -| `AppSettingsService` | `get_bool` pour `registration_enabled` |
18 -
19 -## Hook `on_user_deleted` implemente
20 -
21 -Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`.
22 -
23 -## Aucun contournement en cours
24 -
25 -Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26.
26 -
27 ----
28 -
29 -## Propositions d'amelioration framework (backlog upstream)
30 -
31 -Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream.
32 -
33 -### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22
34 -
35 -**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
36 -
37 -Proposition A retenue :
38 -- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB).
39 -- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body.
40 -- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B).
41 -- Multipart skip, body > cap → 413, 11 tests TDD.
42 -- `csrf_protection` existante inchangee (backwards-compat).
43 -
44 ----
45 -
46 -### 1bis. Details historiques (avant resolution)
47 -
48 -**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard).
49 -
50 -**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`.
51 -
52 -**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement :
53 -
54 -```rust
55 -// For form data, we would need to parse the body
56 -// This is handled in handlers that process form data
57 -```
58 -
59 -Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de :
60 -- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence),
61 -- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate).
62 -
63 -**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui :
64 -1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification.
65 -2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable).
66 -3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch).
67 -4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`.
68 -
69 -Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust.
70 -
71 -**Propositions d'amelioration** (par ordre de preference) :
72 -
73 -#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body`
74 -
75 -Ajouter un parametre optionnel au middleware pour activer la lecture du body :
76 -
77 -```rust
78 -// Nouveau dans rustwarden_core::middleware::csrf
79 -pub struct CsrfProtectionConfig {
80 - /// Si true, lit le champ `csrf_token` du body form-urlencoded
81 - /// quand le header `X-CSRF-Token` est absent. Bufferise le body
82 - /// en RAM (attention aux uploads multipart — ignores).
83 - /// Defaut : false (compatibilite avec le comportement existant).
84 - pub accept_form_body: bool,
85 - /// Taille max du body bufferise. Defaut : 1 MiB.
86 - pub max_form_body_bytes: usize,
87 -}
88 -
89 -impl Default for CsrfProtectionConfig {
90 - fn default() -> Self {
91 - Self {
92 - accept_form_body: false,
93 - max_form_body_bytes: 1024 * 1024,
94 - }
95 - }
96 -}
97 -
98 -/// Nouveau constructeur pour apps SSR.
99 -pub async fn csrf_protection_with_config(
100 - State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
101 - session: Session,
102 - uri: Uri,
103 - request: Request,
104 - next: Next,
105 -) -> Result<Response, AppError> {
106 - // ... flow existant + branche buffer-body si cfg.accept_form_body
107 -}
108 -```
109 -
110 -**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework.
111 -**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered).
112 -
113 -#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>`
114 -
115 -Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware :
116 -
117 -```rust
118 -// Nouveau dans rustwarden_core::middleware::csrf
119 -/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded`
120 -/// URL-encode. Retourne `None` si le champ est absent ou le body malforme.
121 -pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... }
122 -```
123 -
124 -**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques.
125 -**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle.
126 -
127 -#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie
128 -
129 -```rust
130 -// Dans rustwarden_core (derriere feature = "ssr-forms")
131 -pub async fn csrf_protection_ssr(
132 - State(db): State<DatabaseConnection>,
133 - session: Session,
134 - uri: Uri,
135 - request: Request,
136 - next: Next,
137 -) -> Result<Response, AppError> {
138 - // Flow complet avec buffer-body + validation
139 -}
140 -```
141 -
142 -**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite.
143 -**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature.
144 -
145 -**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee.
146 -
147 -**Criteres d'acceptation** pour le PR upstream :
148 -- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat).
149 -- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`.
150 -- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body.
151 -- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser).
152 -- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement.
153 -- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413.
154 -- [ ] Doc inline precisant la trade-off buffer vs streaming.
155 -
156 -**Migration cote gitrust apres merge upstream** :
157 -1. Bump version rustwarden-core dans `Cargo.toml`.
158 -2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests.
159 -3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`.
160 -4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu").
161 -
162 -### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22
163 -
164 -**Status** : Implemente en Phase 1.1 du plan d'evolution.
165 -
166 -Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee.
167 -
168 ----
169 -
170 -### 2bis. Details historiques (avant resolution)
171 -
172 -**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client.
173 -
174 -**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`.
175 -
176 -**Proposition** : ajouter un constructeur async qui fait le fetch du token :
177 -
178 -```rust
179 -impl LayoutContext {
180 - /// Version async qui peuple `csrf_token` depuis la session tower-sessions.
181 - /// Fallback silencieux sur `""` si la session est indisponible (log warn).
182 - pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self {
183 - let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session)
184 - .await
185 - .unwrap_or_default();
186 - Self::new(title, &token)
187 - }
188 -}
189 -```
190 -
191 -**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues.
192 -**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`.
193 -
194 -**Criteres d'acceptation** :
195 -- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat).
196 -- [ ] Nouveau `from_session` disponible, teste avec une session mock.
197 -- [ ] Doc inline avec exemple de handler qui l'utilise.
198 -
199 -**Migration cote gitrust apres merge** :
200 -1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`.
201 -2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant.
202 -
203 -### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22
204 -
205 -**Status** : Implemente en Phase 1.2 du plan d'evolution.
206 -
207 -Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees.
208 -
209 ----
210 -
211 -### 3bis. Details historiques (avant resolution)
212 -
213 -**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`.
214 -
215 -**Proposition** : deduplication via une fonction interne commune + 2 wrappers :
216 -```rust
217 -async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... }
218 -pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... }
219 -pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... }
220 -```
221 -
222 -**Priorite** : basse (DRY seulement, pas de bug).
223 -
224 -### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23
225 -
226 -**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
227 -
228 -Livre :
229 -- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`).
230 -- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`.
231 -- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check.
232 -- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s).
233 -- Prelude exporte `PersonalTokenService` et `TokenScope`.
234 -- 15 tests (5 entity + 10 integration DB).
235 -
236 ----
237 -
238 -### 4bis. Details historiques (avant resolution)
239 -
240 -**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11).
241 -
242 -**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel.
243 -
244 -**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre :
245 -- Token SHA-256 en base, verifie a chaque appel (pas de rotation).
246 -- Scopes structures (`read:repo`, `write:repo`, `admin:repo`).
247 -- `last_used_at` pour UX et nettoyage.
248 -- Expiration optionnelle (`expires_at NULL` = token permanent).
249 -
250 -**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee :
251 -- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at).
252 -- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond).
253 -- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`.
254 -- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust).
255 -
256 -**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur.
257 -
258 -```rust
259 -pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
260 - fn as_str(&self) -> &str;
261 - fn parse(s: &str) -> Option<Self>;
262 -}
263 -
264 -pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> }
265 -
266 -impl<S: TokenScope> PersonalTokenService<S> {
267 - pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>;
268 - pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>;
269 - pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>;
270 - pub async fn revoke(db, token_id, user_id) -> Result<()>;
271 - pub async fn cleanup_expired(db) -> Result<u64>;
272 -}
273 -```
274 -
275 -Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`.
276 -
277 -**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app.
278 -**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe).
279 -
280 -**Criteres d'acceptation** :
281 -- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app.
282 -- [ ] Hash SHA-256 en base, token clair jamais stocke.
283 -- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`.
284 -- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent.
285 -- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT).
286 -
287 -**Migration cote gitrust apres merge** :
288 -1. Bump `rustwarden-core`.
289 -2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`.
290 -3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP).
291 -
292 -### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23
293 -
294 -**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
295 -
296 -Livre :
297 -- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal.
298 -- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests.
299 -- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests.
300 -- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert.
301 -- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`.
302 -
303 -Total : 22 tests nouveaux sur DB reelle, 0 regression.
304 -
305 ----
306 -
307 -### 5bis. Details historiques (avant resolution)
308 -
309 -**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14).
310 -
311 -**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe.
312 -
313 -**Limitation du framework** : rustwarden-core n'expose que :
314 -- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`.
315 -- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`).
316 -
317 -**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger.
318 -
319 -**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee :
320 -- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at).
321 -- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present).
322 -- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`.
323 -
324 -**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`.
325 -
326 -```rust
327 -pub struct TeamService;
328 -
329 -impl TeamService {
330 - pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>;
331 - pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>;
332 - pub async fn remove_member(db, team_id, user_id) -> Result<()>;
333 - pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>;
334 - pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>;
335 - pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>;
336 -}
337 -
338 -// Evolution de ResourceService
339 -pub enum Principal { User(Uuid), Team(Uuid) }
340 -impl ResourceService {
341 - pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>;
342 - pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>;
343 - // Calcule max(perm_direct, max(perm_via_teams))
344 -}
345 -```
346 -
347 -**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit.
348 -**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join).
349 -
350 -**Criteres d'acceptation** :
351 -- [ ] Migration `teams` + `team_members` ajoutable au migrator.
352 -- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust.
353 -- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie).
354 -- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous).
355 -- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`.
356 -- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source.
357 -
358 -**Migration cote gitrust apres merge** :
359 -1. Bump `rustwarden-core`.
360 -2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework.
361 -3. Ajouter UI `/teams` + templates.
362 -4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`.
363 -
364 -### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22
365 -
366 -**Status** : Implemente en Phase 2.3 du plan d'evolution.
367 -
368 -Livre :
369 -- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`.
370 -- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON.
371 -- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.).
372 -- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400.
373 -
374 ----
375 -
376 -### 6bis. Details historiques (avant resolution)
377 -
378 -**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`).
379 -
380 -**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.).
381 -
382 -**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont :
383 -- **Internes** a chaque service, non reutilisables.
384 -- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire.
385 -- Pas d'API publique `RateLimitService::check(key, per_minute)`.
386 -
387 -Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login).
388 -
389 -**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee :
390 -- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante.
391 -- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`.
392 -- Pas de persistence (reset au restart acceptable pour ce scope).
393 -
394 -**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding).
395 -
396 -```rust
397 -pub struct RateLimitService {
398 - buckets: Arc<DashMap<String, WindowCounter>>,
399 -}
400 -
401 -impl RateLimitService {
402 - pub fn new() -> Self;
403 -
404 - /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement.
405 - pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>;
406 -
407 - /// Variante avec fenetre custom (ex. 1h pour reset password).
408 - pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>;
409 -
410 - /// Purge periodique des buckets expires (tache de fond).
411 - pub async fn cleanup_loop(&self, interval: Duration);
412 -}
413 -```
414 -
415 -Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`.
416 -
417 -**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`).
418 -**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1).
419 -
420 -**Criteres d'acceptation** :
421 -- [ ] `RateLimitService::check(key, per_minute)` public.
422 -- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`.
423 -- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif).
424 -- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes).
425 -- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere.
426 -
427 -**Migration cote gitrust apres merge** :
428 -1. Bump `rustwarden-core`.
429 -2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement.
430 -3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import.
431 -
432 -### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23
433 -
434 -**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
435 -
436 -Livre :
437 -- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`.
438 -- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop.
439 -- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables.
440 -- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration).
441 -- Feature `redis-bus` documentee comme extension v2 (hors scope v1).
442 -
443 ----
444 -
445 -### 7bis. Details historiques (avant resolution)
446 -
447 -**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`).
448 -
449 -**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events :
450 -- Barre de progression d'import de repo (`GET /imports/:id/stream`).
451 -- Notifications CI (deja en prod cote gitrust — Phase 5).
452 -- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR.
453 -
454 -**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`).
455 -
456 -**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`.
457 -
458 -**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut.
459 -
460 -```rust
461 -#[async_trait]
462 -pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
463 - /// Publie a tous les abonnes matchant le filtre.
464 - async fn publish(&self, event: T);
465 -
466 - /// Souscrit avec filtre. Retourne un flux tokio-stream.
467 - fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static)
468 - -> Pin<Box<dyn Stream<Item = T> + Send>>;
469 -}
470 -
471 -pub struct InMemoryBus<T> { tx: broadcast::Sender<T> }
472 -impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... }
473 -
474 -// Helper axum pour convertir une souscription en reponse SSE
475 -pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>;
476 -```
477 -
478 -Feature optionnelle `redis-bus` pour impl multi-instance.
479 -
480 -**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete).
481 -**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep.
482 -
483 -**Criteres d'acceptation** :
484 -- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`.
485 -- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut).
486 -- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher.
487 -- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`).
488 -- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`).
489 -
490 -**Migration cote gitrust apres merge** :
491 -1. Bump `rustwarden-core`.
492 -2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework.
493 -3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`.
494 -4. Publier `ImportProgress` sur le bus depuis `import_worker`.
495 -
496 -### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22
497 -
498 -**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
499 -
500 -Ajoutes avec impl par defaut no-op (backwards-compatible) :
501 -- `on_resource_registered(db, resource_type, resource_id, owner_id)`
502 -- `on_resource_unregistered(db, resource_type, resource_id)`
503 -- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)`
504 -- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)`
505 -- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique
506 -
507 -`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests).
508 -
509 ----
510 -
511 -### 8bis. Details historiques (avant resolution)
512 -
513 -**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR).
514 -**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques.
515 -
516 -**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements :
517 -- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted`
518 -- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login`
519 -- RBAC : `on_role_changed`
520 -- Resources : `on_resource_shared`, `on_resource_unshared`
521 -
522 -**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent :
523 -- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service.
524 -- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5).
525 -- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook).
526 -
527 -**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs).
528 -
529 -**Workaround actuel cote gitrust** :
530 -- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add).
531 -- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants).
532 -
533 -**Proposition upstream** : evoluer `RustwardenHooks` en deux temps.
534 -
535 -**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) :
536 -
537 -```rust
538 -#[async_trait]
539 -pub trait RustwardenHooks: Send + Sync {
540 - // Existants
541 - async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) }
542 - async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) }
543 - async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) }
544 - async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) }
545 -
546 - // Nouveaux
547 - async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
548 - async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) }
549 - async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
550 - async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) }
551 - async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
552 - async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
553 -
554 - // Generique (escape hatch pour evenements custom)
555 - async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) }
556 -}
557 -
558 -pub struct AuditEvent<'a> {
559 - pub kind: &'a str, // ex. "issue.created", "pr.merged"
560 - pub actor_id: Option<Uuid>,
561 - pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id)
562 - pub metadata: &'a serde_json::Value,
563 -}
564 -```
565 -
566 -**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison.
567 -
568 -**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService).
569 -**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework.
570 -
571 -**Criteres d'acceptation** :
572 -- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif).
573 -- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage.
574 -- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B).
575 -- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee.
576 -
577 -**Migration cote gitrust apres merge** :
578 -1. Bump `rustwarden-core`.
579 -2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`).
580 -3. Implementer `on_team_*` quand LOT 14 arrive.
581 -4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local.
582 -
583 ----
584 -
585 -## Protocole de remontee upstream
586 -
587 -Quand une amelioration est mergee upstream :
588 -1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`.
589 -2. Supprimer la section correspondante de ce fichier.
590 -3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version).
591 -
592 -Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif.
1 +# Usage du framework Rustwarden (vendored)
2 +
3 +Version synchronisée : voir `.rustwarden-version` à la racine.
4 +Resynchronisation uniquement via `update-project` (depuis le dépôt
5 +Rustwarden) — jamais de modification manuelle de `crates/rustwarden-core/`.
6 +Référence API : `docs/SERVICES_REFERENCE.md` (resynchronisé à chaque update).
7 +
8 +## Services consommés
9 +
10 +| Service / primitive | Usage BotPlace |
11 +|---|---|
12 +| Auth JWT + sessions (mode headless) | Login SSR fourni côté app ; pages protégées via `register_protected_prefix` (`/config`, `/chat` dans `src/main.rs` ; `/home`, `/profile`, `/settings`, `/admin` via `botplace_ui::with_ssr_auth_policy`) |
13 +| `rustwarden_core::auth::auth_api_routes()` | Routeur machine canonique (`/api/v1/auth/*` : login, refresh, 2FA, reset, vérification, changement d'email, limite IP sur les routes pré-session) monté tel quel par `botplace_ui::machine_routes()` + `/health` (plan 006, tranche C) |
14 +| Users / teams | Identités et groupes du cabinet (teams = « groupes » de la spec) |
15 +| RBAC (rôles/permissions) | Distinction admin vs collaborateur (file des déviances, adoption) |
16 +| `EmailChangeService::admin_force_change` | Correction d'adresse par l'admin depuis la fiche compte (`PUT /admin/users/{id}`) — audit framework `email_changed_by_admin`, sessions de la cible révoquées (plan 006, tranche D) |
17 +| `ensure_default_admin` (bootstrap) | Création de l'admin au démarrage (seul chemin qui accepte le nom réservé `admin`) ; réutilisé par les harnais de tests (`tests/common/mod.rs`) |
18 +| `SecurityHeadersConfig` | CSP explicite (`DEFAULT_CONTENT_SECURITY_POLICY`, avec `'unsafe-eval'`) via `botplace_ui::with_alpine_csp` — voir Workarounds |
19 +| CSRF structurel (canal browser) | Toutes les routes HTMX du plan 001 |
20 +| Rate limiting | Défauts du framework ; limite IP sur login/register/refresh/2FA héritée d'`auth_api_routes()` |
21 +| Hooks `on_audit_event` | Prévu : alimentation du journal d'audit métier (tranche 6) |
22 +| Personal Access Tokens | Prévu : authentification des instances hermes-agent (canal machine, plan ultérieur) |
23 +
24 +## Hooks implémentés
25 +
26 +`on_user_login` (`src/main.rs`, `BotplaceHooks`) : couche utilisateur,
27 +composition scellée, provisionnement de l'instance — jamais bloquant.
28 +
29 +## Workarounds / besoins upstream
30 +
31 +Format : date, besoin, workaround appliqué, renvoi RFC upstream si le besoin
32 +persiste.
33 +
34 +### 2026-10-02 — CSP par défaut sans `'unsafe-eval'` vs build Alpine standard
35 +
36 +- **Besoin** : les écrans de gouvernance chargent `crates/web/static/alpine.min.js`
37 + (build Alpine 3.14.9 standard, 98 expressions inline dans 10 templates) qui
38 + compile ses expressions par `new Function`. La CSP par défaut 1.1.0 (RFC 0039
39 + phase 2) retire `'unsafe-eval'` : toute l'interactivité casserait en silence.
40 +- **Workaround** : `botplace_ui::with_alpine_csp(builder)` repose la politique
41 + 1.0.1 (`DEFAULT_CONTENT_SECURITY_POLICY`), constante fournie par le framework
42 + exactement pour ce retour arrière. Test : `crates/botplace-ui/tests/politique_csp.rs`.
43 +- **Sortie** : migration vers `@alpinejs/csp` + composants `Alpine.data(..)`
44 + (chantier à formuler). Aucune RFC upstream : le framework documente ce chemin.
45 +
46 +### 2026-10-02 — `.auth_failure_mode(..)` / `.login_path(..)` du builder non honorés par `AuthService`/`AdminService`
47 +
48 +- **Besoin** : garantir la redirection SSR vers `/login` quel que soit
49 + l'environnement (plan 005, A2).
50 +- **Constat** : dans `RustwardenApp::build`, les couches `AuthService` et
51 + `AdminService` sont posées **à l'extérieur** de l'`Extension(Arc<AuthConfig>)`.
52 + Au moment où elles s'exécutent l'extension est absente ; elles retombent sur
53 + `AuthConfig::from_env()` (fallback assumé par un commentaire du code) et
54 + lisent donc `AUTH_FAILURE_MODE` / `LOGIN_PATH` dans l'environnement. Les
55 + overrides du builder ne sont honorés que par les extracteurs internes
56 + (`auth_required`, `admin_required`). Mis en évidence le 2026-10-02 par
57 + `prefixes_proteges.rs` (première exécution sur base disponible).
58 +- **Workaround** : `AUTH_FAILURE_MODE=redirect` documenté dans `.env.example`
59 + (défaut framework identique si absent) ; le builder garde l'override pour
60 + les chemins qui le lisent ; le test fixe l'environnement à cette valeur.
61 +- **RFC upstream à ouvrir** : `AuthService::new` / `AdminService::new` doivent
62 + recevoir l'`AuthConfig` résolue à la construction (comme
63 + `security_headers_middleware` capture son `Arc`), ou l'`Extension` doit être
64 + posée plus à l'extérieur que ces couches.
65 +
66 +## Adaptations aux breaking changes
67 +
68 +### 2026-10-02 — `update-project` vers `9f3c757` (1.1.0 + RFC 0039 phase 2)
69 +
70 +`cargo check` vert sans modification : ruptures **comportementales**
71 +uniquement. Détail, Gherkin et décisions :
72 +`docs/plans/006-rustwarden-1.1-adaptation.md`. Hors `crates/rustwarden-*` :
73 +
74 +1. **RFC 0036 (`Username`)** — `create_user("admin")` refusé (nom réservé) :
75 + les harnais de tests bootstrapent l'admin par `ensure_default_admin`
76 + (`crates/{web,botplace-ui}/tests/common/mod.rs`), le nom `admin` restant
77 + nécessaire (couche `users/admin` des fixtures, relue depuis la base par les
78 + handlers). Formulaires `user_new`, `user_edit`, `profile` : `pattern`,
79 + `maxlength="39"`, aide en français.
80 +2. **1.0.1 (`AdminConfig::validate`)** — `ADMIN_PASSWORD=password` refusé sur
81 + le chemin de création : mot de passe fort dans les tests (`admin_env()`) ;
82 + `.env` local à corriger (secret).
83 +3. **1.0.1 (limiteurs partagés)** — les services reset / validation partagent
84 + un limiteur « 3 emails / heure / adresse » par processus : les scénarios de
85 + `comptes.rs` invitent « claire » sur une adresse propre à chacun.
86 +4. **RFC 0039 phase 2** — CSP : voir Workarounds.
87 +5. **`auth_api_routes()` enrichi** — `machine_routes()` délègue au routeur
88 + canonique au lieu d'une liste recopiée (qui n'avait ni limite IP sur le
89 + login, ni routes email-change). Test : `tests/canal_machine.rs`.
90 +6. **RFC 0041** — `update_profile` refuse toute nouvelle adresse : fiche admin →
91 + `admin_force_change` ; profil → email en lecture seule (self-service à
92 + formuler). Tests : `comptes.rs`.
93 +7. **Migrations 10 et 11** (`totp_last_used_step`, `pending_email_changes`) :
94 + jouées sur la base de dev par `cargo run --bin migrate` ; `auto_migrate(true)`
95 + les joue au boot en production mono-instance.
96 +
97 +Non concernés : RFC 0037 (aucun patch TOTP local), contrainte
98 +`chk_email_queue_email_type` (non redéfinie), hook `auth.login_blocked` (non
99 +implémenté), pin SeaORM (déjà `2.0.0`), `ADMIN_USERNAME=admin` (bootstrap).
100 +
101 +### 2026-08-01 — `update-project` vers `6261a6b`
102 +
103 +Trois adaptations, toutes hors `crates/rustwarden-*` (voir
104 +`docs/plans/005-rustwarden-a3-protected-prefixes-migration.md`) :
105 +
106 +1. **Pin SeaORM** — le core est passé de `2.0.0-rc.20` à `2.0.0` stable ;
107 + `Cargo.toml` racine (jamais touché par `update-project`) épinglait
108 + `=2.0.0-rc.41` → résolution de dépendances impossible. Pin aligné sur
109 + `2.0.0`. Aucune rupture d'API métier (`cargo check` vert sans autre
110 + modification).
111 +2. **A3 — préfixes protégés vidés par défaut** (`with_ui` les réenregistre,
112 + BotPlace ne l'appelle pas) : `/home`, `/profile`, `/settings` et `/admin`
113 + seraient devenus publics. Réenregistrés via
114 + `botplace_ui::PROTECTED_PREFIXES` / `with_ssr_auth_policy`.
115 +3. **A2 — `.headless()` bascule `auth_failure_mode` sur `Json401`** : l'UI SSR
116 + aurait renvoyé `401 {"error":"unauthorized"}` au lieu d'une redirection vers
117 + `/login`. `AuthFailureMode::Redirect` imposé par le builder. Concerne aussi
118 + `/admin`, dont `AdminService` honore désormais le même mode d'échec.
119 + **Correctif 2026-10-02** : l'override du builder n'atteint pas les couches
120 + `AuthService`/`AdminService` (voir Workarounds) ; le contrat réel est
121 + `AUTH_FAILURE_MODE=redirect` dans l'environnement.
122 +
123 +Non-régression : `crates/botplace-ui/tests/prefixes_proteges.rs` (monte
124 +l'application par le vrai builder ; ✅ vert le 2026-10-02 avec
125 +`AUTH_FAILURE_MODE=redirect`).
M docs/auth-middleware-layering.md
+126 -87
@@ -1,27 +1,67 @@
1 -# Middleware d'auth : ordre des layers et fallback `AuthConfig`
1 +# Middleware d'auth : résolution de l'`AuthConfig` et ordre des layers
2 2
3 3 > Mode Diátaxis : **explanation** (comprendre la mécanique) + un mini
4 4 > **how-to** de troubleshooting à la fin. Référence pour les consommateurs
5 -> de `rustwarden-core` qui mappent leurs propres routes auth-required.
5 +> de `rustwarden-core` qui mappent leurs propres routes auth-required ou
6 +> composent leur pile tower à la main.
6 7
7 8 ## Pourquoi cette page existe
8 9
9 -Deux RFC se sont succédé pour stabiliser le middleware
10 -[`AuthService`](../crates/rustwarden-core/src/middleware/auth_service.rs) :
10 +Trois RFC se sont succédé pour stabiliser les middlewares
11 +[`AuthService`](../crates/rustwarden-core/src/middleware/auth_service.rs) et
12 +[`AdminService`](../crates/rustwarden-core/src/middleware/admin_service.rs) :
11 13
12 14 - [RFC 0022 — fallback `AuthConfig::from_env()` côté branche protected](rfcs/0022-smoke-tests-regression.md) (correctif `d4d6828`).
13 15 - [RFC 0031 — fallback symétrique côté branche optional auth](rfcs/0031-auth-service-optional-fallback.md).
16 +- [RFC 0042 — les middlewares reçoivent l'`AuthConfig` résolue à la construction](rfcs/0042-auth-middleware-resolved-auth-config.md) (train 1.2.0).
17 +
18 +Les deux premières corrigeaient un symptôme : l'ordre des layers tower
19 +configuré par défaut dans [`app.rs`](../crates/rustwarden-core/src/app.rs)
20 +place `AdminService` et `AuthService` **avant** le layer
21 +`Extension(Arc<AuthConfig>)`, donc au moment où ces middlewares
22 +s'exécutent l'extension n'est pas encore dans `request.extensions()`.
23 +Sans fallback, le résultat était soit une erreur 500 silencieuse (avant
24 +RFC 0022) soit des `claims = None` silencieux (avant RFC 0031).
25 +
26 +Mais le fallback avait un effet de bord : `from_env()` ne connaît pas les
27 +overrides du builder. `.login_path(..)`, `.auth_failure_mode(..)` et la
28 +bascule `Json401` de `.headless()` n'atteignaient **jamais** les deux
29 +middlewares. La RFC 0042 traite la cause : depuis 1.2.0, chaque middleware
30 +porte l'`Arc<AuthConfig>` résolue par `RustwardenBuilder::build()`.
31 +
32 +## Résolution de la configuration (depuis 1.2.0)
33 +
34 +Les deux middlewares résolvent l'`AuthConfig` dans cet ordre :
35 +
36 +1. **Configuration capturée à la construction** (`with_auth_config`). C'est
37 + la voie canonique : `build()` charge l'environnement, applique les
38 + overrides du builder, puis passe l'`Arc` aux constructeurs. Aucune
39 + dépendance à l'ordre des layers.
40 +2. `Extension(Arc<AuthConfig>)` lue dans la requête — **chemin legacy**,
41 + uniquement si le middleware a été construit avec `new` (déprécié).
42 +3. `AuthConfig::from_env()` — chemin legacy de dernier recours, même
43 + condition. Un `tracing::warn!` est émis une fois par processus pour
44 + signaler l'usage de l'API dépréciée.
45 +
46 +Sur le chemin canonique, le middleware **pose lui-même** l'`Arc` capturé
47 +dans `request.extensions()` avant de passer la main. Les extracteurs
48 +internes (`AuthUser`, `auth_required`, `admin_required`, `admin_helper`)
49 +voient donc exactement la même instance, y compris dans une pile composée
50 +à la main sans layer `Extension`. Dans le câblage canonique, le layer
51 +`Extension(Arc<AuthConfig>)` d'`app.rs` réinsère ensuite le même `Arc` :
52 +c'est un no-op volontairement conservé.
53 +
54 +### Précédence des overrides
55 +
56 +`RustwardenBuilder::build()` résout le mode d'échec ainsi :
14 57
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).
58 +```
59 +.auth_failure_mode(..) > AUTH_FAILURE_MODE (env) > bascule .headless() (Json401) > Redirect
60 +```
22 61
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.
62 +`.login_path(..)` prime sur `LOGIN_PATH`, qui prime sur `/login`. Une valeur
63 +d'environnement explicite n'est pas un « défaut » : elle l'emporte sur la
64 +bascule `headless`, ce qui permet un rollback sans redéploiement de code.
25 65
26 66 ## Les deux branches de `AuthService`
27 67
@@ -30,61 +70,44 @@ chemin demandé :
30 70
31 71 | Branche | Condition d'entrée | Mode | Réaction si JWT absent / invalide |
32 72 |---|---|---|---|
33 -| **Protected** | `path` commence par un préfixe de `protected_prefixes` | Authentification **obligatoire** | Redirige vers `/login?redirect=…` (303) |
73 +| **Protected** | `path` commence par un préfixe de `protected_prefixes` | Authentification **obligatoire** | `auth_failure_mode` : `303` vers `login_path?redirect=…` ou `401 {"error":"unauthorized"}` |
34 74 | **Optional auth** | `path` ne matche aucun préfixe protégé | Authentification **best-effort** | Aucune action ; la requête traverse avec `claims = None` |
35 75
36 -Dans les deux branches, dès qu'un JWT est présent et valide les
37 -claims sont injectés dans `request.extensions()` :
76 +Dans les deux branches, dès qu'un JWT est présent, valide et non
77 +blacklisté, les claims sont injectés dans `request.extensions()` :
38 78
39 79 - `Extension<Claims>` (struct fortement typée)
40 80 - `Extension<String>` (JSON sérialisé, utile pour les templates Askama)
41 81
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>
82 +`AdminService` applique la même logique, avec vérification du rôle
83 +`admin`, sur `/admin*` et `/api/v1/admin*`.
84 +
85 +## Composer sa propre pile tower
86 +
87 +```rust
88 +use std::sync::Arc;
89 +use rustwarden_core::config::auth::AuthConfig;
90 +use rustwarden_core::middleware::{admin_service::AdminService, auth_service::AuthService};
91 +use tower::ServiceBuilder;
92 +
93 +let auth_config = Arc::new(AuthConfig::from_env()?);
94 +let prefixes = Arc::new(vec!["/api/private".to_string()]);
95 +let app = router.layer(
96 + ServiceBuilder::new()
97 + .layer_fn({
98 + let (db, cfg) = (db.clone(), auth_config.clone());
99 + move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone())
100 + })
101 + .layer_fn({
102 + let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone());
103 + move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone())
104 + })
105 + .into_inner(),
106 +);
107 +// Pas besoin de poser Extension(Arc<AuthConfig>) soi-même : le middleware le fait.
83 108 ```
84 109
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`).
110 +Exemple complet et compilable : [`app/examples/custom_auth_stack.rs`](../app/examples/custom_auth_stack.rs).
88 111
89 112 ## Quand ai-je besoin de `register_protected_prefix()` ?
90 113
@@ -93,16 +116,14 @@ Le `RustwardenBuilder` expose `register_protected_prefix(prefix)` pour
93 116 légitimes de l'utiliser :
94 117
95 118 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.
119 + routes, selon `auth_failure_mode`. Sans préfixe protégé, un visiteur
120 + anonyme atteindra le handler et il devra refuser lui-même via
121 + `Extension<Claims>` (qui provoque un `400 Bad Request` axum) ou un
122 + check explicite.
101 123
102 124 2. **Activer les vérifications additionnelles** (must-change-password,
103 125 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.
126 + protected.
106 127
107 128 À l'inverse, **pas besoin** de déclarer un préfixe protégé juste pour
108 129 que les claims soient injectés : depuis RFC 0031, la branche optional
@@ -110,41 +131,59 @@ suffit.
110 131
111 132 ## Troubleshooting
112 133
134 +### « Mon app headless répond 303 vers /login au lieu de 401 »
135 +
136 +Symptôme du bug corrigé par la RFC 0042 : avant 1.2.0, la bascule
137 +`Json401` de `.headless()` n'atteignait pas les middlewares. Depuis
138 +1.2.0, `headless()` produit bien un `401` sauf si `AUTH_FAILURE_MODE`
139 +ou `.auth_failure_mode(..)` disent autre chose. Si vous êtes encore sur
140 +une version antérieure : posez `AUTH_FAILURE_MODE=json401` dans
141 +l'environnement.
142 +
143 +### « Mon app headless + SSR répond 401 au lieu de rediriger vers /login »
144 +
145 +Conséquence attendue de la RFC 0042 pour une app `.headless()` qui
146 +comptait sur l'ancien comportement (bug). Deux remèdes :
147 +
148 +- dans le code : `.auth_failure_mode(AuthFailureMode::Redirect)` sur le builder ;
149 +- sans redéploiement : `AUTH_FAILURE_MODE=redirect` dans l'environnement.
150 +
151 +### « `.login_path("/auth/signin")` est ignoré »
152 +
153 +Avant 1.2.0 : bug RFC 0042, seule la variable `LOGIN_PATH` était lue par
154 +les middlewares. Depuis 1.2.0 : vérifiez que vous passez par
155 +`RustwardenApp::builder()` ou, si vous composez votre pile, que vous
156 +utilisez `with_auth_config` et non `new` (déprécié : cherchez le warning
157 +`deprecated constructor path` dans les logs au premier appel).
158 +
113 159 ### « Login → /dashboard redirige vers /login sans erreur »
114 160
115 161 Symptôme historique du bug RFC 0031 : le cookie JWT est bien posé,
116 162 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** :
163 +préfixes protégés) redirige vers `/login`.
120 164
121 165 1. Confirmer que le cookie est transmis :
122 166 ```bash
123 167 curl -v -b /tmp/cookies.txt http://localhost:4000/dashboard 2>&1 | grep -E "HTTP|cookie"
124 168 ```
125 169 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.
170 +3. Vérifier la version de `rustwarden-core` : antérieure à RFC 0031 →
171 + bumper, ou déclarer `register_protected_prefix("/dashboard")` comme
172 + contournement.
135 173
136 174 ### « Le serveur renvoie 500 sur /admin sans log clair »
137 175
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.
176 +Sur une pile composée avec `new` (déprécié) et sans `JWT_SECRET` :
177 +`AuthConfig::from_env()` échoue. Le log
178 +`AdminService: AuthConfig unavailable (no extension and from_env failed)`
179 +précise l'erreur. Passez à `with_auth_config` : la configuration est
180 +validée une fois au démarrage et ce chemin disparaît.
143 181
144 182 ## Voir aussi
145 183
146 -- [RFC 0022 — fallback protected](rfcs/0022-smoke-tests-regression.md) (commit `d4d6828`)
184 +- [RFC 0042 — `AuthConfig` résolue à la construction](rfcs/0042-auth-middleware-resolved-auth-config.md)
147 185 - [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)
186 +- [RFC 0022 — fallback protected](rfcs/0022-smoke-tests-regression.md) (commit `d4d6828`)
187 +- Sources : [`auth_service.rs`](../crates/rustwarden-core/src/middleware/auth_service.rs), [`admin_service.rs`](../crates/rustwarden-core/src/middleware/admin_service.rs)
188 +- Tests : [`tests/auth_service_resolved_config.rs`](../crates/rustwarden-core/tests/auth_service_resolved_config.rs) (RFC 0042), [`tests/middleware_authconfig_fallback.rs`](../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) (chemin legacy)
150 189 - Guide consommateur fédération : [`fediverse-consumer-guide.md`](fediverse-consumer-guide.md) §4 « Wire-up »
A docs/rfcs/0042-auth-middleware-resolved-auth-config.md
+579 -0
@@ -0,0 +1,579 @@
1 +# RFC 0042 — Auth — `AuthService` / `AdminService` reçoivent l'`AuthConfig` résolue à la construction
2 +
3 +- **Auteur** : EBO (origine BotPlace — `docs/UPSTREAM_RUSTWARDEN.md`, § « Workarounds / besoins upstream », 2026-10-02)
4 +- **Date** : 2026-10-02
5 +- **Statut** : **Implemented** (2026-10-02, train 1.2.0) — D1 tranchée par le mainteneur en faveur de l'Alternative A ; rupture de défaut assumée, consommateurs adaptés manuellement au déploiement. Plan d'exécution local (dossier `docs/plans/`, ignoré par git) : `2026-10-02-rfc-0042-implementation.md`.
6 +- **Apps consommatrices concernées** : BotPlace (bug reproduit par `crates/botplace-ui/tests/prefixes_proteges.rs`) ; tout projet généré par `rustwarden-cli start-project` en mode headless ; `app/examples/minimal_headless.rs` ; gitrust et rs_pasterman dès qu'ils utilisent `.login_path(..)` ou `.auth_failure_mode(..)`.
7 +- **Crates cibles** : `rustwarden-core` (`middleware/auth_service.rs`, `middleware/admin_service.rs`, `app.rs`) ; `rustwarden-ui` (`handlers/root_handler.rs`, correctif compagnon de trois lignes).
8 +- **Type** : correctif structurel + dépréciation (API additive, aucune suppression avant la prochaine major) + **un changement de défaut effectif** à arbitrer (D1).
9 +- **Précédents** : [RFC 0022](0022-smoke-tests-regression.md) (fallback `from_env()` branche protected), [RFC 0031](0031-auth-service-optional-fallback.md) (fallback branche optional). La présente RFC reprend l'idée de l'Alternative C de la RFC 0031 (L248-257, écartée alors parce qu'elle **modifiait** la signature de `new`) en la rendant additive par un second constructeur.
10 +
11 +## Summary
12 +
13 +Donner à `AuthService` et `AdminService` l'`Arc<AuthConfig>` résolue une fois par `RustwardenBuilder::build()` au lieu de la rechercher à chaque requête dans les extensions puis de retomber sur `AuthConfig::from_env()`, afin que `.login_path()`, `.auth_failure_mode()` et la bascule `.headless()` soient enfin honorés par les middlewares de préfixe.
14 +
15 +## Motivation
16 +
17 +### Constat (BotPlace, 2026-10-02 — vérifié sur `main` `5df0405`)
18 +
19 +Dans [`app.rs`](../../crates/rustwarden-core/src/app.rs) (L788-811), le `ServiceBuilder` empile, du plus externe au plus interne (le premier `.layer()` est le plus externe) :
20 +
21 +```
22 +cors → security_headers → AdminService → AuthService → session → locale
23 + → Extension(i18n) → Extension(hooks) → Extension(auth_config) → Extension(db) → Extension(analytics) → router
24 +```
25 +
26 +Trois `Router::layer` successifs (L820, L828, L831) posent ensuite `Extension(revocation_checker)`, `Extension(dashboard_cards)` et `Extension(oauth_registry)` **encore plus à l'extérieur** que `cors` — ce qui est l'idiome du dépôt quand une extension doit être visible d'`AuthService` (commentaire L815-818 : « so `AuthService` can consult it »). `Extension(auth_config)` n'a pas reçu ce traitement.
27 +
28 +`AdminService` et `AuthService` lisent `parts.extensions.get::<Arc<AuthConfig>>()` ([`auth_service.rs`](../../crates/rustwarden-core/src/middleware/auth_service.rs) L239-258 branche protected, L470-484 branche optional ; [`admin_service.rs`](../../crates/rustwarden-core/src/middleware/admin_service.rs) L72-90). À cet instant l'extension n'est pas encore posée : ils retombent **systématiquement** sur `AuthConfig::from_env()`.
29 +
30 +Or `build()` (L513-526) applique les overrides du builder **sur sa copie locale** avant de la poser en `Extension` :
31 +
32 +```rust
33 +let mut auth_config = AuthConfig::from_env()?;
34 +if let Some(path) = self.login_path.take() { auth_config.login_path = path; }
35 +if let Some(mode) = self.auth_failure_mode { auth_config.auth_failure_mode = mode; }
36 +else if self.headless { auth_config.auth_failure_mode = AuthFailureMode::Json401; }
37 +```
38 +
39 +Cette copie n'est jamais vue par les deux middlewares.
40 +
41 +### Les huit sites `AuthConfig::from_env()` en chemin de requête
42 +
43 +| # | Site | Voit l'extension dans le câblage canonique ? |
44 +|---|---|---|
45 +| 1 | `auth_service.rs` L245 (branche protected) | **Non** → `from_env()` à chaque requête |
46 +| 2 | `auth_service.rs` L475 (branche optional) | **Non** → `from_env()` à chaque requête authentifiée |
47 +| 3 | `admin_service.rs` L78 | **Non** → `from_env()` à chaque requête `/admin*` |
48 +| 4 | `rustwarden-ui/src/handlers/root_handler.rs` L48 (`GET /` avec cookie JWT) | **N'essaie même pas** : `from_env()` direct |
49 +| 5 | `auth_service.rs` L71 (`auth_failure_from_extensions`) | Oui (appelé depuis les extracteurs) |
50 +| 6 | `middleware/admin_helper.rs` L59 | Oui |
51 +| 7 | `auth/extractors.rs` L200 (`AuthUser`) | Oui |
52 +| 8 | `middleware/auth_required.rs` L47 | Oui |
53 +
54 +Les sites 1 à 4 sont dans le périmètre de cette RFC. Les sites 5 à 8 fonctionnent correctement aujourd'hui (ils s'exécutent après le layer `Extension`) et restent hors périmètre (voir Non-goals).
55 +
56 +### Quatre conséquences observables
57 +
58 +1. **Les setters publics du builder sont inopérants sur les deux points d'application principaux.** `.login_path("/auth/signin")` et `.auth_failure_mode(AuthFailureMode::Redirect)` sont documentés, acceptés, puis ignorés par `AuthService` (préfixes protégés) et `AdminService` (`/admin*`, `/api/v1/admin*`). Le contrat réel devient `LOGIN_PATH` / `AUTH_FAILURE_MODE` dans l'environnement — exactement le workaround que BotPlace a dû documenter dans son `.env.example`.
59 +
60 +2. **La promesse de `.headless()` n'est pas tenue.** La rustdoc de `headless()` (`app.rs` L370-376) et `docs/SERVICES_REFERENCE.md` (L1335) annoncent : « bascule le défaut sur `Json401` ». En pratique, un projet headless qui enregistre `register_protected_prefix("/api/v1/private")` répond `303 See Other → /login?redirect=…` vers une page qui n'existe pas, sauf si `AUTH_FAILURE_MODE=json401` est posé dans l'environnement. `/api/v1/admin/*` (couvert par `AdminService`) a le même défaut.
61 +
62 +3. **Comportement incohérent dans une même application.** Les sites 5 à 8 honorent le builder ; les sites 1 à 3 ne l'honorent pas. Dans la même app headless : `/whoami` (extracteur `AuthUser`, câblé par le scaffold CLI) → `401 JSON` ; `/api/v1/private` (préfixe protégé) → `303 → /login`.
63 +
64 +4. **Coût et bruit sur le hot path.** `from_env()` est rejoué à chaque requête sur préfixe protégé, à chaque requête `/admin*`, à chaque requête hors préfixe portant un JWT **non blacklisté** (`auth_service.rs` L452-463), et sur `GET /` avec cookie pour les apps `with_ui` : seize lectures d'environnement, allocations, validation du secret, et un `tracing::info!("Auth configuration loaded…")` (`config/auth.rs` L224-229) **par requête**. Le chemin « `500` si `from_env()` échoue » reste atteignable à chaud si l'environnement du processus est modifié après le démarrage — la configuration validée au boot peut dériver silencieusement.
65 +
66 +### Pourquoi les tests ne l'attrapent pas
67 +
68 +- [`tests/middleware_authconfig_fallback.rs`](../../crates/rustwarden-core/tests/middleware_authconfig_fallback.rs) teste `from_env()` lui-même (idempotence, roundtrip de signature), pas la propagation des overrides.
69 +- Les tests du builder (`app.rs` L1098 `headless_sets_flag_without_ui_prefixes`) vérifient le flag, pas le comportement HTTP résultant.
70 +- Aucun test n'instancie `AuthService` avec un `AuthConfig` explicite : c'est impossible sans muter l'environnement du processus (`unsafe { set_var }` en édition 2024), ce qui sérialise les tests et interdit la parallélisation.
71 +
72 +### Historique
73 +
74 +Les RFC 0022 puis 0031 ont ajouté puis symétrisé le fallback au lieu de traiter la cause (résolution par extension dépendante de l'ordre des layers). La RFC 0031 listait l'injection par constructeur et l'écartait faute de besoin consommateur et par crainte d'un breaking sur `new`. BotPlace fournit le besoin ; un second constructeur plus une dépréciation permettent de livrer la partie « injection » en minor.
75 +
76 +### Conséquences si on ne fait rien
77 +
78 +Chaque consommateur headless ou à UI SSR propre redécouvre le piège (gitrust 2026-05-16, BotPlace 2026-10-02). Deux setters publics restent une API documentée mais sans effet sur les middlewares de préfixe. `docs/auth-middleware-layering.md` présente comme « fallback » ce qui est le chemin nominal.
79 +
80 +**Urgence** : modérée. Le workaround par variables d'environnement existe. Mais l'écart entre l'API publique du builder et le comportement observé entame la crédibilité du framework auprès de ses consommateurs.
81 +
82 +## Non-goals
83 +
84 +- Ne pas changer la sémantique des deux branches d'`AuthService` (protected / optional), ni la liste `protected_prefixes`, ni `register_protected_prefix`.
85 +- Ne pas retirer les fallbacks `from_env()` des sites 5 à 8 (`auth_failure_from_extensions`, `admin_helper`, `AuthUser`, `auth_required`) : ils fonctionnent aujourd'hui et deviennent de simples défenses ; leur suppression est planifiée avec celle des constructeurs dépréciés (prochaine major).
86 +- Ne pas supprimer le layer `Extension(Arc<AuthConfig>)` d'`app.rs` : il reste la source des handlers ; redondance acceptée (voir OQ3).
87 +- Ne pas toucher à `AuthConfig::from_env()` ni au parsing de `AUTH_FAILURE_MODE` / `LOGIN_PATH`. En particulier, **ne pas ajouter de champ à `AuthConfig`** : la struct (`config/auth.rs` L37-75) a tous ses champs `pub` et n'est pas `#[non_exhaustive]`, un champ supplémentaire casserait toute construction par littéral (dont le helper de test `auth_service.rs` L514-520).
88 +- Ne pas supporter l'override **par requête** de la configuration via `Extension(Arc<AuthConfig>)` sur les middlewares de préfixe : la configuration capturée à la construction est autoritaire (aucun site du workspace ne pose une telle extension par requête ; le seul producteur est `app.rs` L809, au démarrage).
89 +- Ne pas introduire de `tower::Layer` dédiés (voir OQ4).
90 +- Pas de changement de schéma DB, pas de changement de format sérialisé.
91 +
92 +## API proposée
93 +
94 +### `AuthService`
95 +
96 +```rust
97 +// crates/rustwarden-core/src/middleware/auth_service.rs
98 +#[derive(Clone)]
99 +pub struct AuthService<S> {
100 + inner: S,
101 + db: DatabaseConnection,
102 + protected_prefixes: Arc<Vec<String>>,
103 + /// Configuration resolved once by `RustwardenBuilder::build()`.
104 + /// `None` only on the deprecated `AuthService::new` path, which keeps
105 + /// the historical lookup (request extension, then `AuthConfig::from_env`).
106 + auth_config: Option<Arc<AuthConfig>>,
107 +}
108 +
109 +impl<S> AuthService<S> {
110 + /// Create the middleware with the authoritative [`AuthConfig`].
111 + ///
112 + /// Used by [`crate::app::RustwardenBuilder::build`] since 1.2.0. The
113 + /// config is captured once at construction (same pattern as
114 + /// `security_headers_middleware`), so builder overrides such as
115 + /// [`crate::app::RustwardenBuilder::login_path`] and
116 + /// [`crate::app::RustwardenBuilder::auth_failure_mode`] are honoured
117 + /// regardless of where `Extension(Arc<AuthConfig>)` sits in the stack.
118 + ///
119 + /// The captured config takes precedence over any
120 + /// `Extension(Arc<AuthConfig>)` found in the request. On every request
121 + /// the middleware also inserts it into the request extensions, so
122 + /// downstream extractors (`AuthUser`, `auth_required`, `admin_required`)
123 + /// observe the very same instance even in hand-composed tower stacks.
124 + ///
125 + /// ```no_run
126 + /// # use std::sync::Arc;
127 + /// # use rustwarden_core::config::auth::AuthConfig;
128 + /// # use rustwarden_core::middleware::auth_service::AuthService;
129 + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
130 + /// let auth_config = Arc::new(AuthConfig::from_env()?);
131 + /// let prefixes = Arc::new(vec!["/api/private".to_string()]);
132 + /// let app = router.layer(tower::layer::layer_fn(move |inner| {
133 + /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone())
134 + /// }));
135 + /// # let _ = app; Ok(()) }
136 + /// ```
137 + pub fn with_auth_config(
138 + inner: S,
139 + db: DatabaseConnection,
140 + protected_prefixes: Arc<Vec<String>>,
141 + auth_config: Arc<AuthConfig>,
142 + ) -> Self {
143 + Self { inner, db, protected_prefixes, auth_config: Some(auth_config) }
144 + }
145 +
146 + /// Legacy constructor: resolves `AuthConfig` per request from the
147 + /// extensions, then from the environment.
148 + #[deprecated(
149 + since = "1.2.0",
150 + note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; \
151 + the per-request `AuthConfig::from_env()` fallback ignores builder \
152 + overrides (`login_path`, `auth_failure_mode`, `headless`). \
153 + Removal no earlier than 1.5.0, effective in the next major."
154 + )]
155 + pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self {
156 + Self { inner, db, protected_prefixes, auth_config: None }
157 + }
158 +}
159 +
160 +/// Authoritative config first; request extension then environment only on
161 +/// the deprecated path. A free function so it can be called from inside the
162 +/// `'static` boxed future returned by `call` (where `self` is unavailable).
163 +fn resolve_auth_config(
164 + captured: Option<&Arc<AuthConfig>>,
165 + extensions: &axum::http::Extensions,
166 +) -> Result<Arc<AuthConfig>, String> {
167 + if let Some(cfg) = captured {
168 + return Ok(cfg.clone());
169 + }
170 + if let Some(cfg) = extensions.get::<Arc<AuthConfig>>() {
171 + return Ok(cfg.clone());
172 + }
173 + AuthConfig::from_env().map(Arc::new)
174 +}
175 +```
176 +
177 +Dans `call` (signature à passer en `fn call(&mut self, mut req: Request<B>)`), **avant** le `Box::pin` :
178 +
179 +```rust
180 +// Seed the request so every downstream extractor sees the same config,
181 +// whatever the consumer's layer ordering. In the canonical wiring the
182 +// `Extension(Arc<AuthConfig>)` layer later re-inserts the same `Arc`
183 +// (a no-op); the seeding only matters for hand-composed stacks.
184 +if let Some(cfg) = &self.auth_config {
185 + req.extensions_mut().insert(cfg.clone());
186 +}
187 +// Cloned here because the boxed future below cannot borrow `self`.
188 +let captured = self.auth_config.clone();
189 +```
190 +
191 +Les deux branches remplacent leur bloc `match parts.extensions.get… / None => from_env()` par `resolve_auth_config(captured.as_ref(), &parts.extensions)`. Le traitement d'erreur (`500` + `tracing::error!` sur protected, traversée sans claims sur optional) est conservé pour le chemin déprécié.
192 +
193 +### `AdminService`
194 +
195 +```rust
196 +// crates/rustwarden-core/src/middleware/admin_service.rs
197 +#[derive(Clone)]
198 +pub struct AdminService<S> {
199 + inner: S,
200 + db: DatabaseConnection,
201 + auth_config: Option<Arc<AuthConfig>>,
202 +}
203 +
204 +impl<S> AdminService<S> {
205 + /// Create the middleware with the authoritative [`AuthConfig`]
206 + /// (see `AuthService::with_auth_config` for the rationale).
207 + ///
208 + /// ```no_run
209 + /// # use std::sync::Arc;
210 + /// # use rustwarden_core::config::auth::AuthConfig;
211 + /// # use rustwarden_core::middleware::admin_service::AdminService;
212 + /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
213 + /// let auth_config = Arc::new(AuthConfig::from_env()?);
214 + /// let app = router.layer(tower::layer::layer_fn(move |inner| {
215 + /// AdminService::with_auth_config(inner, db.clone(), auth_config.clone())
216 + /// }));
217 + /// # let _ = app; Ok(()) }
218 + /// ```
219 + pub fn with_auth_config(inner: S, db: DatabaseConnection, auth_config: Arc<AuthConfig>) -> Self {
220 + Self { inner, db, auth_config: Some(auth_config) }
221 + }
222 +
223 + #[deprecated(
224 + since = "1.2.0",
225 + note = "use `AdminService::with_auth_config(inner, db, auth_config)`. \
226 + Removal no earlier than 1.5.0, effective in the next major."
227 + )]
228 + pub fn new(inner: S, db: DatabaseConnection) -> Self {
229 + Self { inner, db, auth_config: None }
230 + }
231 +}
232 +```
233 +
234 +Même fonction libre `resolve_auth_config` (partagée via `pub(crate)` depuis `auth_service.rs`), même seeding, même `mut req`.
235 +
236 +### `RustwardenBuilder::build()` (`app.rs`)
237 +
238 +```rust
239 +// 3. Auth config & session
240 +let failure_mode_set_in_env = std::env::var_os("AUTH_FAILURE_MODE").is_some();
241 +let auth_config = self.apply_auth_overrides(
242 + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?,
243 + failure_mode_set_in_env,
244 +);
245 +// Must stay AFTER `apply_auth_overrides`: the session layer reads
246 +// `session_timeout_minutes` / cookie settings from the final config.
247 +let session_layer = SessionConfig::create_memory_layer(&auth_config);
248 +let auth_config = Arc::new(auth_config);
249 +let cfg_for_admin = auth_config.clone();
250 +let cfg_for_auth = auth_config.clone();
251 +// ...
252 +ServiceBuilder::new()
253 + .layer(cors_layer)
254 + .layer(/* security_headers_middleware, unchanged */)
255 + .layer_fn(move |service| {
256 + AdminService::with_auth_config(service, db_for_admin.clone(), cfg_for_admin.clone())
257 + })
258 + .layer_fn(move |service| {
259 + AuthService::with_auth_config(
260 + service,
261 + db_for_auth.clone(),
262 + prefixes_for_auth.clone(),
263 + cfg_for_auth.clone(),
264 + )
265 + })
266 + .layer(session_layer)
267 + // ...
268 + .layer(axum::Extension(auth_config)) // kept on purpose, see OQ3
269 +```
270 +
271 +Extraction d'une fonction **pure** (aucune lecture d'environnement ; le signal env est passé en paramètre) pour la précédence des overrides :
272 +
273 +```rust
274 +impl RustwardenBuilder {
275 + /// Apply builder overrides on top of the environment-loaded config.
276 + ///
277 + /// Precedence (D1): explicit setter > `AUTH_FAILURE_MODE` in the
278 + /// environment > `headless()` default flip > `Redirect`.
279 + fn apply_auth_overrides(&self, mut cfg: AuthConfig, failure_mode_set_in_env: bool) -> AuthConfig {
280 + if let Some(path) = &self.login_path {
281 + cfg.login_path = path.clone();
282 + }
283 + if let Some(mode) = self.auth_failure_mode {
284 + cfg.auth_failure_mode = mode;
285 + } else if self.headless && !failure_mode_set_in_env {
286 + cfg.auth_failure_mode = AuthFailureMode::Json401;
287 + }
288 + cfg
289 + }
290 +}
291 +```
292 +
293 +### Correctif compagnon — `rustwarden-ui/src/handlers/root_handler.rs` (site 4)
294 +
295 +```rust
296 +// Prefer the config resolved at build time; keep `from_env()` as a defence
297 +// for stacks that do not pose the extension.
298 +let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() {
299 + Some(cfg) => cfg,
300 + None => match AuthConfig::from_env() {
301 + Ok(cfg) => Arc::new(cfg),
302 + Err(_) => { /* unchanged: warn + redirect to login */ }
303 + },
304 +};
305 +```
306 +
307 +Trois lignes, aucune API touchée. Nécessaire pour que les énoncés de performance et d'observabilité ci-dessous soient vrais aussi pour les apps `with_ui`.
308 +
309 +### Exemple consommateur : pile tower composée à la main (hors builder)
310 +
311 +```rust
312 +let auth_config = Arc::new(AuthConfig::from_env()?);
313 +let app = router.layer(
314 + ServiceBuilder::new()
315 + .layer_fn({
316 + let (db, cfg) = (db.clone(), auth_config.clone());
317 + move |s| AdminService::with_auth_config(s, db.clone(), cfg.clone())
318 + })
319 + .layer_fn({
320 + let (db, cfg, p) = (db.clone(), auth_config.clone(), prefixes.clone());
321 + move |s| AuthService::with_auth_config(s, db.clone(), p.clone(), cfg.clone())
322 + })
323 + .into_inner(),
324 +);
325 +// No need to add Extension(Arc<AuthConfig>) yourself: the middleware seeds it.
326 +```
327 +
328 +À livrer tel quel dans `app/examples/custom_auth_stack.rs` (processus §8, item 5 : exemple obligatoire pour un pattern middleware).
329 +
330 +### Reproduction (avant / après)
331 +
332 +Diff réel sur `app/examples/minimal_headless.rs` :
333 +
334 +```diff
335 + RustwardenApp::builder()
336 + .from_env()
337 + .headless()
338 + .auto_migrate(true)
339 ++ .register_protected_prefix("/api/status")
340 + .merge_machine_routes(api_routes())
341 +```
342 +
343 +```bash
344 +unset AUTH_FAILURE_MODE
345 +cargo run -p rustwarden-app --example minimal_headless &
346 +curl -si http://localhost:3000/api/status | head -2
347 +# Avant : HTTP/1.1 303 See Other ← attendu 401 en headless
348 +# location: /login?redirect=%2Fapi%2Fstatus
349 +# Après : HTTP/1.1 401 Unauthorized
350 +# {"error":"unauthorized"}
351 +```
352 +
353 +## Analyse de compatibilité
354 +
355 +- [x] **API existante étendue** (`with_auth_config` sur les deux middlewares, `apply_auth_overrides` privé) → non-breaking, minor.
356 +- [x] **API existante dépréciée, non supprimée** → `#[deprecated]` avec note et remplacement. Processus §5 : suppression possible au plus tôt **trois minors** après la dépréciation (1.2.0 → 1.5.0), effective dans la première major ultérieure ; entrée `Deprecated` à reporter dans **chaque** minor intermédiaire. Appelants de `new` dans le workspace au 2026-10-02 : `app.rs` L797 et L799 uniquement ; les templates `rustwarden-cli` n'appellent pas les constructeurs ; aucun appel dans `rustwarden-ui`, `rustwarden-fediverse*`, `app/`.
357 +- [ ] **API existante modifiée ou supprimée** → non. Mais voir la ligne suivante.
358 +- [x] **Nouveau comportement par défaut effectif** → **oui**, et c'est le point à arbitrer (**D1**). Le processus §1 (principe 1) qualifie « une modification du comportement par défaut » de breaking « même si le code compile », §5 interdit tout breaking en minor, et §6 tranche l'ambiguïté en faveur de « stable » (les codes HTTP du happy path sont listés comme stables). Population exposée : apps `.headless()` **sans** `.auth_failure_mode(..)` **ni** `AUTH_FAILURE_MODE` posé → leurs préfixes protégés et `/api/v1/admin*` passent de `303 → /login` (bug) à `401 JSON` (défaut documenté depuis `05aeaca`, `SERVICES_REFERENCE.md` L1335). Pour les apps `with_ui(..)` : rien ne change.
359 + - **Recensement des consommateurs connus** : BotPlace est `.headless()` avec UI SSR propre, mais appelle `.auth_failure_mode(AuthFailureMode::Redirect)` sur le builder **et** pose `AUTH_FAILURE_MODE=redirect` (`docs/UPSTREAM_RUSTWARDEN.md`, § A2 et § Workarounds) → **non affectée**, quelle que soit la précédence retenue. gitrust et rs_pasterman sont des apps `with_ui` → non affectées. Les projets scaffoldés par `rustwarden-cli` en headless n'enregistrent aucun préfixe protégé par défaut et protègent `whoami` par l'extracteur `AuthUser` (déjà `Json401`) → seul `/api/v1/admin*` change de code, dans le sens attendu par un client API. **Population résiduelle** : apps headless + SSR qui s'appuient sur le `303` sans setter ni env — aucune connue ; remédiation sans redéploiement de code : `AUTH_FAILURE_MODE=redirect`.
360 + - **Précédence retenue (ex-OQ1)** : setter > env > bascule `headless` > `Redirect`. `headless()` documente une *bascule de défaut* ; une valeur d'environnement explicite n'est pas un défaut. C'est aussi ce qui réduit la population exposée au strict minimum ci-dessus.
361 + - **D1 — à trancher par le mainteneur** : (a) livrer la bascule en 1.2.0 comme restauration d'un défaut documenté, avec entrée CHANGELOG `Changed`, rollback par variable d'environnement et patch `1.2.1` de secours (Alternative A, recommandée) ; ou (b) livrer l'injection en 1.2.0 mais garder la bascule headless derrière un opt-in jusqu'à la prochaine major (Alternative E, strictement non-breaking au sens §1).
362 +- [x] **Projets headless déjà générés par le CLI et déployés** : passent par `update-project` / `.rustwarden-version`. À vérifier chez eux avant adoption : `grep -rn register_protected_prefix src/` et clients de `/api/v1/admin*` qui attendraient un `303`.
363 +- [x] **Extracteurs internes** (sites 5 à 8) : inchangés ; ils voient la même instance qu'avant.
364 +- [ ] Changement de schéma DB → non.
365 +- [ ] Changement de format sérialisé → non.
366 +- [x] **Logs** : disparition du `Auth configuration loaded` par requête sur les sites 1 à 4 (format des logs = non-stable, §6).
367 +- [x] **Versions** : les sept manifestes `Cargo.toml` du workspace sont à `1.0.0` alors que le CHANGELOG a publié `1.0.1` et `1.1.0` (seuls tags git : `V0.1.0`, `v01`). La numérotation de cette RFC (`1.2.0`, hotfix `1.1.1`) suit le CHANGELOG ; à réaligner au moment du train.
368 +
369 +## Alternatives considérées
370 +
371 +### Alternative A (recommandée) : injection par constructeur + seeding + dépréciation + bascule headless effective
372 +
373 +Décrite ci-dessus, avec la précédence setter > env > headless.
374 +
375 +**Pros** : supprime structurellement la dépendance à l'ordre des layers pour les sites 1 à 3 ; une seule `AuthConfig` validée au boot pour tous les points d'application ; retire `from_env()` du hot path (sites 1 à 4) ; rend `AuthService` / `AdminService` testables sans environnement ni base ; pattern déjà adopté par `security_headers_middleware` (RFC 0039) ; corrige aussi les piles composées à la main ; la promesse de `headless()` devient vraie partout.
376 +
377 +**Cons** : deux constructeurs à maintenir jusqu'à la major ; ~90 lignes sur quatre fichiers ; un défaut effectif change pour une population résiduelle (D1) ; deux sources de la même instance dans la requête (seeding + layer `Extension`), inertes l'une par rapport à l'autre dans le câblage canonique.
378 +
379 +### Alternative B : remonter `Extension(auth_config)` en tête de pile
380 +
381 +Poser `.layer(axum::Extension(auth_config.clone()))` en `Router::layer` après le `ServiceBuilder`, comme `revocation_checker` (`app.rs` L815-820).
382 +
383 +**Pros** : trois lignes, aucun changement d'API, livrable en patch `1.1.1` ; débloque BotPlace immédiatement ; **c'est l'idiome déjà employé dans le dépôt** pour rendre une extension visible d'`AuthService`.
384 +
385 +**Cons** : la correction dépend toujours de l'ordre des layers (le bug reviendra au prochain réagencement, comme après `d4d6828`) ; les piles composées à la main par les consommateurs restent cassées ; le fallback `from_env()` par requête subsiste en code mort défensif ; les tests exigent toujours de muter l'environnement. Écartée par la RFC 0031 pour les mêmes raisons. **Compatible avec A comme mesure transitoire** (OQ2).
386 +
387 +### Alternative C : `tower::Layer` dédiés (`AuthLayer`, `AdminLayer`)
388 +
389 +**Pros** : plus idiomatique ; les consommateurs réutilisent le layer sans closure.
390 +
391 +**Cons** : surface d'API supplémentaire à stabiliser ; ne corrige rien de plus que A ; peut être ajouté par-dessus A ultérieurement (OQ4).
392 +
393 +### Alternative D : ne rien faire (documenter que le contrat réel est l'environnement)
394 +
395 +**Pros** : zéro coût immédiat.
396 +
397 +**Cons** : deux setters publics restent une API sans effet ; la rustdoc de `headless()` et `SERVICES_REFERENCE.md` sont fausses ; chaque consommateur headless redécouvre le piège ; coût et log par requête conservés ; impossibilité de tester les overrides sans `unsafe { set_var }`.
398 +
399 +### Alternative E : injection en 1.2.0, bascule headless différée derrière un opt-in
400 +
401 +Identique à A pour les constructeurs, le seeding et la dépréciation. Mais `apply_auth_overrides` ne bascule `Json401` sur `headless()` que si le consommateur appelle un nouveau setter `.headless_json401(true)` (ou active une feature `experimental-headless-json401`, processus §5 « feature flags » : naissance en minor, promotion après deux apps adoptantes et 30 jours, défaut en major).
402 +
403 +**Pros** : strictement non-breaking au sens §1 ; les setters explicites `.login_path()` / `.auth_failure_mode()` sont honorés dès 1.2.0 (c'est le besoin BotPlace) ; la bascule arrive en major avec son guide `MIGRATION-V2.md`.
404 +
405 +**Cons** : prolonge volontairement pendant tout un cycle l'incohérence entre `AuthUser` (déjà `Json401` en headless) et les middlewares de préfixe ; ajoute un setter ou une feature temporaire à retirer ensuite ; la rustdoc de `headless()` reste fausse jusqu'à la major sauf à la réécrire deux fois.
406 +
407 +**Choix proposé** : Alternative A, avec B acceptée comme hotfix optionnel (OQ2) et E comme repli si le mainteneur tranche D1 en faveur d'une lecture stricte de §1.
408 +
409 +## Testing plan
410 +
411 +Les tests unitaires ci-dessous tournent **sans PostgreSQL** (`MockDatabase` via la dev-dépendance `sea-orm/mock` déjà déclarée) et **n'exigent aucune variable d'environnement**. Attention : ils ne peuvent pas *garantir* l'absence de `JWT_SECRET`, car plusieurs `mod tests` du même binaire de lib appellent `dotenvy::dotenv().ok()` (`hooks.rs` L381, `tasks/pending_email_change_cleanup.rs` L100, …) et chargent le `.env` du dépôt dans le processus. La preuve que `with_auth_config` ne touche plus `from_env()` est donc portée par le test #6 ci-dessous, dans un **binaire d'intégration dédié**. Pattern HTTP : `Router` + `tower::ServiceExt::oneshot`, comme les tests de `security_headers_middleware` dans `app.rs`.
412 +
413 +### Mock de la blacklist
414 +
415 +`JwtBlacklistService::is_blacklisted` (`services/jwt_blacklist_service.rs` L147-162) exécute un `count()`. Sur la branche protected, `Err(_)` est traité comme « blacklisté » (`auth_service.rs` L272-278) : un `MockDatabase` sans résultat court-circuite donc **avant** `validate_token`. Les tests #4 et #5 doivent appendre un résultat de comptage (aucun précédent dans le dépôt, qui ne mocke que des `Model`) :
416 +
417 +```rust
418 +use sea_orm::{DatabaseBackend, MockDatabase, Value};
419 +use std::collections::BTreeMap;
420 +
421 +fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> {
422 + BTreeMap::from([("num_items", Value::BigInt(Some(n)))])
423 +}
424 +let db = MockDatabase::new(DatabaseBackend::Postgres)
425 + .append_query_results([vec![blacklist_count(0)]])
426 + .into_connection();
427 +```
428 +
429 +### Unitaires — `auth_service.rs`
430 +
431 +1. **Happy** : préfixe protégé, pas de JWT, `Json401` injecté → `401` + corps `{"error":"unauthorized"}` (aucune requête DB émise).
432 +2. **Happy** : `Redirect` + `login_path = "/auth/signin"` injectés → `303` + `Location: /auth/signin?redirect=%2Fprivate`.
433 +3. **Seeding** : route hors préfixe, handler lisant `Extension<Arc<AuthConfig>>`, **aucun** layer `Extension` dans la pile de test → le `login_path` renvoyé est celui injecté.
434 +4. **Erreur — signature invalide** : JWT signé avec un autre secret, blacklist mockée à `0` (le flux atteint `validate_token`), `Json401` → `401`. Assertion complémentaire : le journal du mock (`into_transaction_log()`) contient exactement une requête `COUNT`.
435 +5. **Erreur — blacklist** : JWT **valide** (signé avec le secret injecté), blacklist mockée à `1` → `401`. Le token valide distingue ce test du #4 : il prouve que le court-circuit blacklist s'applique à un token qui aurait autrement passé.
436 +6. **Legacy / preuve d'indépendance à l'environnement** — dans `crates/rustwarden-core/tests/auth_service_resolved_config.rs` (binaire isolé, pattern `ENV_LOCK` + `remove_var("JWT_SECRET")` de `tests/middleware_authconfig_fallback.rs` L19-46) : avec `JWT_SECRET` retiré, `AuthService::with_auth_config` répond `401` sur un préfixe protégé là où `AuthService::new` (`#[allow(deprecated)]`) répond `500` ; avec `Extension` posée en amont, `new` l'utilise (comportement historique conservé).
437 +
438 +### Unitaires — `admin_service.rs`
439 +
440 +7. `/admin` et `/api/v1/admin/x` sans JWT, `Json401` → `401` ; `Redirect` + `login_path` custom → `303` vers ce chemin.
441 +
442 +### Unitaires — `app.rs`
443 +
444 +8. `apply_auth_overrides` (fonction pure, parallélisable) : `headless()` → `Json401` ; setter `Redirect` prime sur `headless()` ; `failure_mode_set_in_env = true` prime sur `headless()` ; `login_path` propagé ; non-headless sans setter → inchangé.
445 +
446 +### Unitaires — `rustwarden-ui`
447 +
448 +9. `root_redirect` avec `Extension(Arc<AuthConfig>)` posée et cookie JWT valide → redirige vers `/home` sans appel à `from_env()` (vérifiable par un `login_path` non standard dans la config injectée, impossible à obtenir depuis l'environnement du test).
449 +
450 +### Intégration
451 +
452 +10. `tests/middleware_authconfig_fallback.rs` conservé tel quel (il couvre `from_env()` et le chemin déprécié).
453 +11. Bout en bout, si un harnais DB est disponible en CI : `RustwardenBuilder::default().headless().register_protected_prefix("/p").build()` + `oneshot` → `401` ; et **test de garde sur l'ordre des layers** : une route dont le handler lit `Extension<Arc<AuthConfig>>` renvoie la config du builder (protège les sites 5 à 8, toujours sensibles à l'ordre). Sinon, couverture consommateur : `prefixes_proteges.rs` (BotPlace), à relancer **sans** `AUTH_FAILURE_MODE`.
454 +
455 +### Non-régression
456 +
457 +12. `cargo build --workspace --examples` sans warning (aucun appel à `new` ne subsiste hors tests).
458 +13. `cargo clippy --workspace` zéro warning ; `cargo doc` sans warning (vérifier en particulier les liens intra-doc vers les items dépréciés).
459 +14. Projet généré par `rustwarden-cli start-project` en headless : compile et répond `401` sur `/api/v1/admin/users` sans token.
460 +
461 +### Doctests
462 +
463 +15. Les exemples `no_run` de `AuthService::with_auth_config` et `AdminService::with_auth_config` compilent.
464 +
465 +### Benchs (processus §7 : obligatoire, hot path touché)
466 +
467 +16. Micro-mesure `AuthConfig::from_env()` seule (`std::time::Instant`, 10 000 itérations) et `oneshot` sur préfixe protégé sans JWT, pile legacy vs pile `with_auth_config`, même machine. Livré : test `#[ignore]` `bench_from_env_versus_captured_config` dans `tests/auth_service_resolved_config.rs` (`cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench`). **Résultat (debug, 2026-10-02)** : `from_env()` 23,7 ms / 10 000 = 2,37 µs par appel ; pile legacy 40,2 µs/req ; pile `with_auth_config` 37,7 µs/req (−6 %). Aucune dépendance de bench ajoutée.
468 +
469 +### Observabilité
470 +
471 +17. `Auth configuration loaded` n'apparaît qu'une fois au démarrage sur une app `with_ui` **et** sur une app headless (sites 1 à 4 couverts).
472 +
473 +## Migration guide
474 +
475 +Section à copier dans `CHANGELOG.md` au merge (`[1.2.0]`).
476 +
477 +```markdown
478 +### Fixed
479 +
480 +- **RFC 0042.** `AuthService` et `AdminService` honorent désormais
481 + `RustwardenBuilder::login_path`, `::auth_failure_mode` et la bascule
482 + `Json401` de `::headless()`. Les deux middlewares retombaient
483 + systématiquement sur `AuthConfig::from_env()` (ordre des layers) et ne
484 + lisaient que `LOGIN_PATH` / `AUTH_FAILURE_MODE`. Préfixes protégés,
485 + `/admin*` et extracteurs `AuthUser` / `auth_required` partagent enfin la
486 + même configuration. `rustwarden-ui::root_redirect` lit aussi la
487 + configuration injectée avant de retomber sur l'environnement.
488 +
489 +### Changed
490 +
491 +- Apps `.headless()` sans `.auth_failure_mode(..)` ni `AUTH_FAILURE_MODE` :
492 + les préfixes enregistrés via `register_protected_prefix` et `/api/v1/admin*`
493 + répondent `401 {"error":"unauthorized"}` au lieu de `303 → /login`
494 + (comportement documenté, jusqu'ici non appliqué). Si votre app headless
495 + sert une UI SSR, appelez `.auth_failure_mode(AuthFailureMode::Redirect)`
496 + ou posez `AUTH_FAILURE_MODE=redirect` (désormais prioritaire sur la
497 + bascule `headless`).
498 +- `AuthConfig::from_env()` n'est plus exécutée à chaque requête par les
499 + middlewares de préfixe ni par `root_redirect` ; dans le câblage canonique,
500 + le log `Auth configuration loaded` n'apparaît plus qu'au démarrage.
501 +
502 +### Deprecated
503 +
504 +- `AuthService::new(inner, db, prefixes)` → `AuthService::with_auth_config(inner, db, prefixes, auth_config)`.
505 +- `AdminService::new(inner, db)` → `AdminService::with_auth_config(inner, db, auth_config)`.
506 + Suppression au plus tôt en 1.5.0, effective dans la prochaine major.
507 +```
508 +
509 +### Migration steps
510 +
511 +1. Bump `rustwarden-core` à `1.2.0` (et réaligner les manifestes du workspace, aujourd'hui à `1.0.0`) ; `cargo build`. **Aucune modification requise** si vous passez par `RustwardenApp::builder()`.
512 +2. Si vous composez vous-même la pile tower : remplacer `new` par `with_auth_config` et passer l'`Arc<AuthConfig>` que vous posiez déjà en `Extension`.
513 +3. Apps headless : vérifier que le `401` sur préfixes protégés et `/api/v1/admin*` est bien le comportement voulu ; sinon `.auth_failure_mode(Redirect)` ou `AUTH_FAILURE_MODE=redirect`.
514 +4. Les `AUTH_FAILURE_MODE` / `LOGIN_PATH` posés uniquement pour contourner le bug peuvent être retirés — ou conservés : ils restent lus par `from_env()` au démarrage.
515 +5. Côté consommateur, fermer l'entrée « RFC upstream à ouvrir » de `docs/UPSTREAM_RUSTWARDEN.md` en pointant vers cette RFC.
516 +
517 +### Rollback (processus §10)
518 +
519 +Régression constatée sur une app headless + SSR : poser `AUTH_FAILURE_MODE=redirect` (sans redéploiement de code). Si la précédence env s'avérait insuffisante : patch `1.2.1` rétablissant `Redirect` comme défaut headless sur les middlewares, les constructeurs `with_auth_config` restant en place.
520 +
521 +### Documentation à mettre à jour dans le même PR
522 +
523 +- [`docs/auth-middleware-layering.md`](../auth-middleware-layering.md) : le fallback `from_env()` devient le chemin **legacy** ; décrire le seeding, `with_auth_config` et la précédence setter > env > headless.
524 +- [`docs/SERVICES_REFERENCE.md`](../SERVICES_REFERENCE.md) L1333-1335 : précédence réelle des overrides.
525 +- `.env.sample` (racine) et `template/.env.sample` : documenter `AUTH_FAILURE_MODE` et `LOGIN_PATH`, aujourd'hui absents (seul `JWT_SECRET` y figure).
526 +- Rustdoc de `RustwardenBuilder::headless`, `login_path`, `auth_failure_mode` : énoncer la précédence et les points d'application couverts.
527 +- `app/examples/custom_auth_stack.rs` : la pile composée à la main ci-dessus.
528 +
529 +## Open questions
530 +
531 +- [x] **OQ1 — Précédence environnement vs bascule `headless`** : tranchée (setter > env > headless > `Redirect`), implémentée par le paramètre `failure_mode_set_in_env` de `apply_auth_overrides` (fonction pure, six tests unitaires).
532 +- [x] **D1 — Train de livraison de la bascule headless** : **Alternative A en 1.2.0** (décision mainteneur du 2026-10-02). La rupture de défaut est assumée et documentée dans le CHANGELOG (section `⚠️ BREAKING`) ; les consommateurs sont adaptés manuellement au déploiement.
533 +- [x] **OQ2 — Hotfix B en `1.1.1`** : sans objet, A livrée directement.
534 +- [x] **OQ3 — Layer `Extension(Arc<AuthConfig>)` d'`app.rs`** : conservé (redondance sans coût) ; retrait avec les constructeurs dépréciés.
535 +- [ ] **OQ4 — `tower::Layer` dédiés** (`AuthLayer`, `AdminLayer`) : RFC séparée si un consommateur en exprime le besoin.
536 +- [x] **OQ5 — Signal sur le chemin déprécié** : implémenté — `tracing::warn!` une fois par processus (`std::sync::Once`) dans `resolve_auth_config` quand le chemin `from_env()` est pris.
537 +
538 +## Écarts d'implémentation par rapport à l'API proposée
539 +
540 +- `build()` résout l'`AuthConfig` **avant** la connexion à la base (étape 0) et non à l'étape 3 : la fonction `apply_auth_overrides(&self, ..)` emprunte le builder alors que `self.db` est déplacé à l'étape 1. Aucun effet fonctionnel ; `SessionConfig::create_memory_layer` reste appelée après les overrides. `build(mut self)` devient `build(self)`.
541 +- `rustwarden-ui::root_redirect` redirige en plus vers `auth_config.login_path` au lieu de `/login` codé en dur (cohérent avec l'intention de la RFC : `.login_path()` honoré partout). Trois tests unitaires dans `rustwarden-ui`.
542 +- Test #11 (bout en bout via `RustwardenBuilder::build()` + `oneshot`) : **non écrit**, aucun harnais de ce type n'existe dans le dépôt (il faudrait base, migrations et admin par défaut). Couvert par les tests unitaires du builder (#8), le binaire d'intégration isolé (#6) et le test consommateur BotPlace `prefixes_proteges.rs` à relancer sans `AUTH_FAILURE_MODE`.
543 +- **Signal environnement durci (revue finale)** : `failure_mode_set_in_env` n'est vrai que pour une valeur `AUTH_FAILURE_MODE` **valide** (`redirect` / `json401`). `from_env()` retombe silencieusement sur `Redirect` quand la valeur est vide ou mal orthographiée ; sans ce durcissement, une faute de frappe dans l'environnement aurait réintroduit le `303` sur une app headless. Fonction pure `RustwardenBuilder::failure_mode_overridden_by_env(raw)` testée (`None`, `""`, `"json-401"`, `"json401"`, `"Redirect"`).
544 +- Le `tracing::warn!` du chemin déprécié est émis **après** un `from_env()` réussi, pour ne jamais précéder un `from_env failed`.
545 +- `build()` échoue désormais sur un `JWT_SECRET` invalide **avant** la connexion DB, les migrations et l'admin par défaut (fail-fast ; consigné au CHANGELOG).
546 +- Les deux doctests `no_run` compilent ; le test d'indépendance à l'environnement vit dans `crates/rustwarden-core/tests/auth_service_resolved_config.rs` avec quatre scénarios (canonique sans env → `401` / `303` custom ; legacy sans env → `500` ; legacy + `Extension` externe → honorée ; legacy + env → `from_env()` et overrides ignorés) plus le bench `#[ignore]`.
547 +
548 +## Suites à donner (hors périmètre, relevées par la revue finale)
549 +
550 +- **Fail-open blacklist dans `AdminService`** (`admin_service.rs`, `is_blacklisted(..).is_ok_and(|b| b)`) : une erreur DB laisse passer la requête admin, alors que `AuthService` est fail-closed (`Ok(true) | Err(_) => reject`). Préexistant, chemin le plus privilégié : ticket dédié.
551 +- `auth_failure_from_extensions` (site 5) garde sa chaîne extension → `from_env()` sans le `warn!` `Once` : la promesse d'observabilité OQ5 ne couvre que les deux middlewares.
552 +- Cinq copies du littéral `AuthConfig` à quinze champs dans les tests (dont deux hors `rustwarden-core`). Proposition : `#[doc(hidden)] pub fn for_tests()` derrière une feature `test-util`, à faire avant tout ajout de champ à `AuthConfig`.
553 +- Le bench tourne sans souscripteur `tracing` : les 2,37 µs par appel sous-estiment le coût réel du `info!` par requête en production.
554 +
555 +## Tracking issue
556 +
557 +À ouvrir sur le dépôt Rustwarden avec les labels `bug`, `area:auth`, `rfc`, `consumer:botplace` ; lien à reporter ici avant tout passage en `Accepted`. Origine : `docs/UPSTREAM_RUSTWARDEN.md` § « Workarounds / besoins upstream » (2026-10-02). Test consommateur de référence : `crates/botplace-ui/tests/prefixes_proteges.rs` (BotPlace), à relancer **sans** `AUTH_FAILURE_MODE` après adoption.
558 +
559 +---
560 +
561 +## Score d'évaluation (auto-évaluation proposée, à confirmer par les reviewers)
562 +
563 +| Critère | Note (1-5) | Commentaire |
564 +|---|---|---|
565 +| Généralité | 5 | Tout consommateur headless ou à chemin de login custom ; projets CLI ; huit sites recensés, quatre corrigés, quatre rendus cohérents |
566 +| Stabilité API | 3 | Ajout pur pour les constructeurs ; changement de défaut effectif sur une population résiduelle, atténué par la précédence env et un rollback sans code (D1). 5 avec l'Alternative E |
567 +| Performance | 4 | Retire un `from_env()` par requête sur les sites 1 à 4. Bench #16 (debug, N = 10 000, 2026-10-02) : `from_env()` seul 2,37 µs/appel ; pile legacy 40,2 µs/req ; pile `with_auth_config` 37,7 µs/req, soit **−6 %** par requête protégée sans JWT, plus la disparition du `tracing::info!` par requête. Gain mesurable mais modeste |
568 +| Sécurité | 4 | Une seule configuration validée au boot ; plus de dérive via l'environnement à chaud ; chemin `500` fermé sur le câblage canonique. Changement de défaut headless à annoncer |
569 +| Complexité d'implémentation | 4 | ~90 lignes sur quatre fichiers + tests ; pattern déjà en place (`security_headers_middleware`) |
570 +| Charge de maintenance | 5 | Zéro dépendance ; supprime du code (fallbacks) à terme |
571 +| Documentation | 4 | Rustdoc, deux doctests, CHANGELOG, exemple, quatre documents à réviser, `.env.sample` à compléter |
572 +| **Total** | **29/35** | ≥ 25 (31/35 avec l'Alternative E) |
573 +
574 +---
575 +
576 +**Historique** :
577 +- 2026-10-02 : Draft. Origine BotPlace (`docs/UPSTREAM_RUSTWARDEN.md`). Câblage vérifié sur `main` `5df0405` : `app.rs` L788-831 et L513-526, `auth_service.rs` L239-258 / L452-484, `admin_service.rs` L72-90, `admin_helper.rs` L59, `extractors.rs` L194-200, `auth_required.rs` L43-47, `config/auth.rs` L212-229, `rustwarden-ui/handlers/root_handler.rs` L48.
578 +- 2026-10-02 : D1 tranchée (Alternative A, 1.2.0, rupture assumée) ; implémentation livrée le jour même : `auth_service.rs`, `admin_service.rs` (7 tests), `app.rs` (`apply_auth_overrides`, 6 tests), `rustwarden-ui/root_handler.rs` (3 tests), `tests/auth_service_resolved_config.rs` (5 tests + bench), `app/examples/custom_auth_stack.rs`, CHANGELOG 1.2.0, manifestes 1.2.0, `auth-middleware-layering.md`, `SERVICES_REFERENCE.md`, `.env.sample`, `template/.env.sample`. Statut → Implemented.
579 +- 2026-10-02 : revue critique interne (REQUEST CHANGES) intégrée → Under Review. Changements : fonction libre `resolve_auth_config` (le `&self` était inaccessible dans le future `'static`), `mut req`, `apply_auth_overrides` pure avec signal env en paramètre, site 4 (`root_redirect`) ajouté au périmètre, recensement des huit sites, mocks `count()` explicites, test d'indépendance à l'environnement en binaire isolé, bench ajouté, Alternative E, checklist breaking restaurée et décision D1 formalisée, fenêtre de dépréciation alignée sur §5, `.env.sample` au lieu de `.env.example`, désalignement des manifestes signalé.
M docs/rfcs/README.md
+3 -0
@@ -98,6 +98,7 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
98 98 | [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Implemented** (phase 2, sans `unsafe-eval`) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
99 99 | [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Implemented** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
100 100 | [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Implemented** | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 |
101 +| [0042](0042-auth-middleware-resolved-auth-config.md) | Auth — `AuthService` / `AdminService` reçoivent l'`AuthConfig` résolue (overrides `.login_path()` / `.auth_failure_mode()` / `.headless()` ignorés par les middlewares de préfixe) | **Implemented** (1.2.0, 2026-10-02 ; D1 = Alternative A, rupture de défaut headless assumée) | EBO (origine BotPlace) | 2026-10-02 |
101 102
102 103 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
103 104
@@ -105,6 +106,8 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
105 106
106 107 > **Note sur les RFCs 0029-0030** : ces deux RFCs trouvent leur origine côté gitrust (premier consommateur), tracking dans [`gitrust/`](gitrust/), évaluation détaillée dans [`docs/plans/2026-05-15-rfcs-gitrust-evaluation.md`](../plans/2026-05-15-rfcs-gitrust-evaluation.md). Une 3ᵉ RFC initialement proposée (`gitrust-0003 RemoteTeamMembershipService`) a été **rejetée** car la capacité est déjà livrée par les RFCs 0008/0009.
107 108
109 +> **Note sur la remontée BotPlace du 2026-10-02** (`docs/UPSTREAM_RUSTWARDEN.md`, § « Workarounds / besoins upstream ») : deux besoins remontés. Le premier (CSP par défaut sans `'unsafe-eval'` vs build Alpine standard) ne donne **pas** lieu à RFC : le chemin est déjà fourni par la [RFC 0039](0039-security-headers-config.md) (`DEFAULT_CONTENT_SECURITY_POLICY` + `RustwardenBuilder::security_headers`), la sortie attendue est la migration consommateur vers `@alpinejs/csp`. Le second (overrides du builder non honorés par `AuthService` / `AdminService`) est un défaut générique du framework → [RFC 0042](0042-auth-middleware-resolved-auth-config.md).
110 +
108 111 ## RFCs liées au plan fediverse
109 112
110 113 Le plan d'intégration de la fédération d'identité ([`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md) §17) prévoit 11 RFCs :
M tools/rustwarden-cli/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-cli"
3 -version = "1.0.0"
3 +version = "1.2.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "CLI wizard to scaffold new Rustwarden applications"