gradatum-server 0.4.1

Stateless HTTP/MCP façade :19090 — handles read/search + enqueues writes
//! Middlewares Axum : authentification JWT + rate limiting.
//!
//! ## Middleware auth JWT
//!
//! Vérifie le bearer JWT Ed25519 via `JwtService` puis contrôle la révocation.
//!
//! ### Logique d'extraction et de révocation
//!
//! 1. Header `Authorization: Bearer <token>` absent → `TrustContext::Unauthenticated`.
//! 2. Header présent, `JwtService::verify(token)` OK →
//!    check révocation : `state.revocation.is_revoked(&jti)` avec timeout 200 ms.
//!    - token révoqué → 401 immédiat (pas d'injection dans les extensions).
//!    - store KO ou timeout → 401 **fail-closed** + `tracing::error!` (jamais de panic ni de 500).
//!    - token valide non révoqué → `TrustContext::BearerToken` injecté.
//! 3. Header présent, verify échoue (exp/kid/sig) →
//!    `TrustContext::Unauthenticated` (le handler retournera 401).
//!
//! ### Politique fail-closed
//!
//! Le check de révocation est fail-closed : toute erreur du store (I/O, SQLite, timeout)
//! aboutit à un 401, jamais à un pass-through silencieux. Raison : la révocation est un
//! mécanisme de sécurité — un store dégradé ne doit pas permettre l'accès avec un token
//! révoqué (ex. token compromis dont l'opérateur a demandé la révocation immédiate).
//!
//! Le `TrustContext` est injecté via `request.extensions_mut().insert(trust)`
//! avant de passer la main à `next.run(request)`.
//!
//! ## Middleware rate limiting
//!
//! [`build_warden_layer`] construit un [`WardenLayer`] (`gradatum-warden` crate L0)
//! à partir de [`RateLimitConfig`].
//!
//! ### Bypass loopback : implémentation réelle
//!
//! Le bypass loopback dans `gradatum_warden::WardenService` appelle directement
//! `inner.call(req)` pour les IPs loopback — le handler métier retourne son body réel.
//! Contrairement à l'ancienne implémentation `tower_governor` (error_handler terminait
//! la chaîne avec `Body::empty()`), les clients loopback reçoivent le body complet.
//!
//! ### Ordre de montage sur le router rate-limité :
//! ```text
//! requête entrante
//!   → WardenLayer (gradatum-warden)
//!       si loopback + bypass_loopback : inner.call(req) direct → body réel handler
//!       si IP filtrée deny : 403
//!       si rate limit dépassé : 429 + retry-after
//!       sinon : inner.call(req) → body réel handler
//!   → handler métier
//! ```

use std::time::Duration;

use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use serde::Serialize;

use crate::config::RateLimitConfig;
use crate::state::AppState;
use gradatum_warden::{WardenConfig, WardenLayer};

// ─── Rate limiting ───────────────────────────────────────────────────────────

/// Construit le [`WardenLayer`] depuis la [`RateLimitConfig`] server.
///
/// Retourne `None` si `!cfg.enabled` (aucun rate limiting appliqué).
///
/// Le [`WardenLayer`] gère :
/// - bypass loopback réel (appel `inner.call(req)` direct — body handler retourné)
/// - filtrage IP CIDR (vide par défaut = tout autorisé)
/// - rate limit per-IP via governor token bucket
pub fn build_warden_layer(cfg: &RateLimitConfig) -> Option<WardenLayer> {
    if !cfg.enabled {
        return None;
    }
    let warden_cfg = WardenConfig {
        enabled: true,
        rate_limit_per_minute: cfg.per_minute,
        rate_limit_burst: cfg.burst,
        bypass_loopback: cfg.exempt_localhost,
        ip_allow: vec![],
        ip_deny: vec![],
    };
    Some(WardenLayer::new(warden_cfg).expect(
        "config warden invalide — per_minute et burst doivent être > 0, \
         garantis par RateLimitConfig::default() (60, 10)",
    ))
}

// ─── Auth JWT ────────────────────────────────────────────────────────────────

/// Timeout pour le check de révocation JWT (fail-closed si dépassé).
///
/// 200 ms : largement supérieur à la latence SQLite locale (<1 ms p99),
/// suffisamment court pour ne pas bloquer les requêtes en cas de store dégradé.
const REVOCATION_CHECK_TIMEOUT: Duration = Duration::from_millis(200);

/// Réponse d'erreur JSON uniforme pour les 401 du middleware.
#[derive(Serialize)]
struct MiddlewareError {
    error: &'static str,
}

/// Construit une réponse 401 JSON uniforme.
fn unauthorized(msg: &'static str) -> Response {
    (StatusCode::UNAUTHORIZED, axum::Json(MiddlewareError { error: msg })).into_response()
}

/// Middleware Axum qui extrait, valide le bearer JWT, puis vérifie la révocation.
///
/// Séquence :
/// 1. `extract_trust` parse le header et vérifie la signature JWT (sync).
/// 2. Si le token est valide (`BearerToken`), le jti est extrait et
///    `state.revocation.is_revoked(&jti)` est appelé avec un timeout de 200 ms
///    (fail-closed : erreur store ou timeout → 401).
/// 3. Le `TrustContext` résultant est injecté dans les extensions de la requête.
///
/// Retourne directement une réponse 401 si :
/// - le token est révoqué,
/// - le store de révocation retourne une erreur,
/// - le check dépasse le timeout de 200 ms.
pub async fn auth_middleware(
    axum::extract::State(state): axum::extract::State<AppState>,
    mut request: Request<Body>,
    next: Next,
) -> Response {
    let (trust, maybe_jti) = extract_trust(&state, &request);

    // Check de révocation uniquement pour les tokens valides (BearerToken).
    // Pour Unauthenticated : le handler métier retournera 401 — pas de check nécessaire.
    if let Some(jti) = maybe_jti {
        match tokio::time::timeout(
            REVOCATION_CHECK_TIMEOUT,
            state.revocation.is_revoked(jti.as_str()),
        )
        .await
        {
            Ok(Ok(true)) => {
                tracing::debug!(jti = %jti, "token JWT révoqué — 401");
                return unauthorized("token révoqué");
            }
            Ok(Ok(false)) => {
                // Token valide et non révoqué — continuer.
            }
            Ok(Err(e)) => {
                tracing::error!(
                    err = %e,
                    "revocation store error — fail-closed (401)"
                );
                return unauthorized("erreur de vérification du token — réessayer plus tard");
            }
            Err(_timeout) => {
                tracing::error!(
                    timeout_ms = REVOCATION_CHECK_TIMEOUT.as_millis(),
                    "revocation check timeout — fail-closed (401)"
                );
                return unauthorized("erreur de vérification du token — réessayer plus tard");
            }
        }
    }

    request.extensions_mut().insert(trust);
    next.run(request).await
}

/// Extrait le [`TrustContext`] depuis les headers HTTP via `JwtService`.
///
/// Retourne un tuple `(TrustContext, Option<jti>)` :
/// - `jti` est `Some` uniquement si le token est valide (`BearerToken`) — utilisé
///   par `auth_middleware` pour le check de révocation async.
/// - `jti` est `None` pour `Unauthenticated` (pas de check de révocation nécessaire).
///
/// Logique :
/// - Pas de header `Authorization` → `(Unauthenticated, None)`.
/// - Header non-`Bearer` → `(Unauthenticated, None)`.
/// - Token vide → `(Unauthenticated, None)`.
/// - Verify OK → `(BearerToken { kid, aud, sub, scopes }, Some(jti))`.
/// - Verify KO (kid/aud/exp/sig) → `(Unauthenticated, None)` (loggé en DEBUG, pas ERROR —
///   les tentatives invalides ne sont pas des erreurs serveur).
fn extract_trust(
    state: &AppState,
    request: &Request<Body>,
) -> (gradatum_core::trust::TrustContext, Option<String>) {
    let header_value = match request.headers().get(axum::http::header::AUTHORIZATION) {
        Some(v) => v,
        None => return (gradatum_core::trust::TrustContext::Unauthenticated, None),
    };

    let raw = match header_value.to_str() {
        Ok(s) => s,
        Err(_) => {
            tracing::debug!("Authorization header contient des octets non-UTF-8 — ignoré");
            return (gradatum_core::trust::TrustContext::Unauthenticated, None);
        }
    };

    let token = match raw.strip_prefix("Bearer ") {
        Some(t) if !t.is_empty() => t,
        _ => return (gradatum_core::trust::TrustContext::Unauthenticated, None),
    };

    match state.jwt.verify(token) {
        Ok(claims) => {
            tracing::debug!(
                sub = %claims.sub,
                tenant = %claims.tenant_id,
                "JWT vérifié avec succès"
            );
            let jti = claims.jti.clone();
            let trust = gradatum_core::trust::TrustContext::BearerToken {
                kid: state.jwt.kid().to_string(),
                aud: claims.aud,
                sub: claims.sub,
                scopes: claims.scopes,
                tenant_id: claims.tenant_id,
            };
            (trust, Some(jti))
        }
        Err(e) => {
            tracing::debug!(err = %e, "JWT invalide — TrustContext::Unauthenticated");
            (gradatum_core::trust::TrustContext::Unauthenticated, None)
        }
    }
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::time::{Duration, SystemTime};

    use axum::body::Body;
    use axum::http::{Request, StatusCode};
    use axum::routing::get;
    use axum::Router;
    use tower::ServiceExt;

    use gradatum_auth::jwt::TokenScope;
    use gradatum_auth::revocation::{RevocationError, RevocationStore};

    use crate::state::AppState;

    // ── Helpers ──────────────────────────────────────────────────────────────

    /// Crée un `AppState` de test avec un `JwtService` éphémère + `InMemoryRevocationStore`.
    fn make_state() -> AppState {
        AppState::new()
    }

    /// Crée un `AppState` avec un store de révocation injecté.
    fn make_state_with_revocation(store: Arc<dyn RevocationStore>) -> AppState {
        let mut state = AppState::new();
        state.revocation = store;
        state
    }

    /// Signe un token de test.
    fn sign_token(state: &AppState, sub: &str) -> (String, String) {
        let token = state
            .jwt
            .sign(sub, &["read".to_string()], TokenScope::Service, "main")
            .expect("sign doit réussir avec une clé éphémère valide");
        // Extraire le jti depuis les claims (vérification round-trip)
        let claims = state.jwt.verify(&token).expect("verify immédiat ne peut pas échouer");
        (token, claims.jti)
    }

    /// Handler de test minimal — retourne 200 OK.
    async fn handler_ok() -> StatusCode {
        StatusCode::OK
    }

    /// Construit un router de test avec `auth_middleware`.
    fn test_router(state: AppState) -> Router {
        Router::new()
            .route("/test", get(handler_ok))
            .layer(axum::middleware::from_fn_with_state(
                state.clone(),
                crate::middleware::auth_middleware,
            ))
            .with_state(state)
    }

    /// Envoie une requête GET /test avec un bearer optionnel, retourne le StatusCode.
    async fn send_request(router: Router, bearer: Option<&str>) -> StatusCode {
        let mut builder = Request::builder().method("GET").uri("/test");
        if let Some(token) = bearer {
            builder = builder.header("Authorization", format!("Bearer {token}"));
        }
        let req = builder.body(Body::empty()).expect("request builder invariant");
        let resp = router.oneshot(req).await.expect("handler ne doit pas paniquer");
        resp.status()
    }

    // ── Test 1 : token valide non révoqué → next appelé → 200 ────────────────

    #[tokio::test]
    async fn test_valid_token_not_revoked_passes() {
        let state = make_state();
        let (token, _jti) = sign_token(&state, "user-test");
        let router = test_router(state);
        let status = send_request(router, Some(&token)).await;
        assert_eq!(status, StatusCode::OK);
    }

    // ── Test 2 : token révoqué → 401 ─────────────────────────────────────────

    #[tokio::test]
    async fn test_revoked_token_returns_401() {
        let state = make_state();
        let (token, jti) = sign_token(&state, "user-revoked");

        // Révoquer le token dans le store.
        let exp = SystemTime::now() + Duration::from_secs(86400);
        state
            .revocation
            .revoke(&jti, exp)
            .await
            .expect("revoke doit réussir sur InMemoryRevocationStore");

        let router = test_router(state);
        let status = send_request(router, Some(&token)).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
    }

    // ── Test 3 : store retourne Err → 401 fail-closed ─────────────────────────

    /// Store de révocation qui retourne toujours une erreur.
    struct AlwaysErrorStore;

    #[async_trait::async_trait]
    impl RevocationStore for AlwaysErrorStore {
        async fn is_revoked(&self, _jti: &str) -> Result<bool, RevocationError> {
            Err(RevocationError::Sqlite(sqlx::Error::RowNotFound))
        }

        async fn revoke(&self, _jti: &str, _exp: SystemTime) -> Result<(), RevocationError> {
            Ok(())
        }

        async fn gc(&self) -> Result<usize, RevocationError> {
            Ok(0)
        }
    }

    #[tokio::test]
    async fn test_store_error_returns_401_fail_closed() {
        let error_store = Arc::new(AlwaysErrorStore);
        let state = make_state_with_revocation(error_store);
        let (token, _jti) = sign_token(&state, "user-store-err");
        let router = test_router(state);
        let status = send_request(router, Some(&token)).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
    }

    // ── Test 4 : timeout → 401 fail-closed ────────────────────────────────────

    /// Store de révocation qui dépasse le timeout (attend 300 ms > REVOCATION_CHECK_TIMEOUT=200ms).
    struct SlowStore;

    #[async_trait::async_trait]
    impl RevocationStore for SlowStore {
        async fn is_revoked(&self, _jti: &str) -> Result<bool, RevocationError> {
            tokio::time::sleep(Duration::from_millis(300)).await;
            Ok(false)
        }

        async fn revoke(&self, _jti: &str, _exp: SystemTime) -> Result<(), RevocationError> {
            Ok(())
        }

        async fn gc(&self) -> Result<usize, RevocationError> {
            Ok(0)
        }
    }

    #[tokio::test]
    async fn test_timeout_returns_401_fail_closed() {
        let slow_store = Arc::new(SlowStore);
        let state = make_state_with_revocation(slow_store);
        let (token, _jti) = sign_token(&state, "user-slow");
        let router = test_router(state);
        let status = send_request(router, Some(&token)).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
    }
}