auth_service.rs 931 lignes · 39360 octets
/// Auth Service Middleware
///
/// Tower Service wrapper that applies auth_required middleware to protected routes.
/// This works around Axum 0.8 Sync limitations with from_fn_with_state.
use axum::{
    extract::Request,
    http::{StatusCode, Uri},
    response::{IntoResponse, Redirect, Response},
};
use sea_orm::DatabaseConnection;
use std::{
    future::Future,
    pin::Pin,
    sync::{Arc, Once},
    task::{Context, Poll},
};
use tower::Service;

use crate::config::auth::{AuthConfig, AuthFailureMode};

/// SEC-C4: Sanitize redirect URL to prevent open redirect attacks.
/// Only allows relative paths starting with '/' (not '//').
/// The path is URL-encoded to prevent parameter injection.
///
/// `login_path` is the destination anonymous visitors are sent to,
/// configurable via [`AuthConfig::login_path`] so the middleware is not
/// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved
/// in a `redirect` query parameter when it is a safe relative path.
pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
    let path = uri.replace('\\', "/");
    if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
        format!("{login_path}?redirect={}", urlencoding::encode(&path))
    } else {
        login_path.to_string()
    }
}

/// Response returned when a protected route is reached without valid
/// credentials, honouring [`AuthConfig::auth_failure_mode`].
///
/// - [`AuthFailureMode::Redirect`] → `303 See Other` to
///   [`AuthConfig::login_path`] (browser UIs).
/// - [`AuthFailureMode::Json401`] → `401 Unauthorized` with a JSON body
///   (headless / API apps, where redirecting to an HTML login page is
///   meaningless).
pub(crate) fn auth_failure_response(auth_config: &AuthConfig, uri: &Uri) -> Response {
    match auth_config.auth_failure_mode {
        AuthFailureMode::Json401 => (
            StatusCode::UNAUTHORIZED,
            axum::Json(serde_json::json!({ "error": "unauthorized" })),
        )
            .into_response(),
        AuthFailureMode::Redirect => {
            let url = sanitize_redirect(uri.path(), &auth_config.login_path);
            Redirect::to(&url).into_response()
        }
    }
}

/// Same as [`auth_failure_response`], for call sites that only hold request
/// extensions (extractors, `from_fn` middlewares that don't pre-resolve the
/// config). Resolves [`AuthConfig`] from the injected `Extension`, else from
/// the environment, else falls back to a default `Redirect` to `/login`.
pub(crate) fn auth_failure_from_extensions(
    extensions: &axum::http::Extensions,
    uri: &Uri,
) -> Response {
    let config = extensions
        .get::<Arc<AuthConfig>>()
        .cloned()
        .or_else(|| AuthConfig::from_env().ok().map(Arc::new));
    match config {
        Some(cfg) => auth_failure_response(&cfg, uri),
        None => {
            let url = sanitize_redirect(uri.path(), "/login");
            Redirect::to(&url).into_response()
        }
    }
}

/// Fail-closed outcome of a SeaORM `find_by_id` (or equivalent) user lookup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UserLookup {
    /// A row was found; the caller still owns the `Model`.
    Found,
    /// No row: treat as unauthenticated (deleted user + leftover JWT).
    Missing,
    /// Database error: refuse the request (503).
    Unavailable,
}

/// Map a SeaORM lookup result to a fail-closed auth decision.
pub(crate) fn map_user_lookup<T, E>(result: Result<Option<T>, E>) -> UserLookup {
    match result {
        Ok(Some(_)) => UserLookup::Found,
        Ok(None) => UserLookup::Missing,
        Err(_) => UserLookup::Unavailable,
    }
}

/// Fail-closed outcome of a forced-2FA TOTP status check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TotpGate {
    /// User already has TOTP enabled; continue.
    Allow,
    /// Forced 2FA is on and the user has not enrolled; redirect to setup.
    RedirectSetup,
    /// Database error: refuse the request (503).
    Unavailable,
}

/// Map `TotpService::is_totp_enabled` to a fail-closed forced-2FA decision.
pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> TotpGate {
    match result {
        Ok(true) => TotpGate::Allow,
        Ok(false) => TotpGate::RedirectSetup,
        Err(_) => TotpGate::Unavailable,
    }
}

/// Tower middleware that injects the authenticated user into request extensions.
///
/// The middleware splits incoming requests into two branches based on
/// whether the request path matches any of the registered
/// `protected_prefixes`:
///
/// - **Protected branch**: authentication is mandatory. A missing or
///   invalid JWT triggers a `303 See Other` redirect to
///   `/login?redirect=<sanitized-path>`. Additional checks run here
///   (JWT blacklist, `must_change_password`, forced 2FA, remote
///   identity revocation).
/// - **Optional auth branch**: authentication is best-effort. The
///   request is allowed through regardless, but when a valid JWT is
///   present the claims are still injected into
///   `request.extensions()` (`Claims` and the JSON-serialised form)
///   so handlers can adapt to authenticated users via
///   `Option<Extension<Claims>>`.
///
/// Both branches resolve the [`crate::config::auth::AuthConfig`]
/// required for JWT verification via the same three-step lookup
/// (RFC 0042):
///
/// 1. The `Arc<AuthConfig>` captured at construction time by
///    [`AuthService::with_auth_config`]. This is the authoritative
///    source: it is immune to tower layer ordering, so builder
///    overrides (`login_path`, `auth_failure_mode`, `headless`) are
///    always honoured.
/// 2. `Extension(Arc<AuthConfig>)` read from the request extensions
///    when a downstream layer has posed it.
/// 3. [`crate::config::auth::AuthConfig::from_env`] as a last resort,
///    reported once per process through a `tracing::warn!`.
///
/// Steps 2 and 3 are the **legacy path of the deprecated
/// [`AuthService::new`] constructor**. They silently drop builder
/// overrides whenever the `Extension` layer sits further inside the
/// stack than this middleware — which is precisely what
/// [`crate::app::RustwardenBuilder::build`] does, since both services
/// are layered outside `Extension(Arc<AuthConfig>)`. The protected
/// branch was patched first (commit `d4d6828`, RFC 0022); the optional
/// branch was aligned by RFC 0031; RFC 0042 added step 1.
///
/// On the protected branch, a failed resolution produces a
/// `500 Internal Server Error` after a `tracing::error!`. On the
/// optional branch, the request simply traverses without claims —
/// matching the semantics of an unauthenticated visitor — and a
/// `tracing::error!` records the misconfiguration.
///
/// See [`docs/auth-middleware-layering.md`](../../../../../docs/auth-middleware-layering.md)
/// for the consumer-facing explanation of layer ordering and
/// `register_protected_prefix` usage.
#[derive(Clone)]
pub struct AuthService<S> {
    inner: S,
    db: DatabaseConnection,
    protected_prefixes: Arc<Vec<String>>,
    /// Config captured at construction. `None` only on the deprecated
    /// [`AuthService::new`] path, where the request extension / environment
    /// fallback applies.
    auth_config: Option<Arc<AuthConfig>>,
}

impl<S> AuthService<S> {
    /// Create a new `AuthService` middleware wrapping the given inner service.
    ///
    /// `protected_prefixes` is the list of URL path prefixes for which the
    /// middleware enforces JWT authentication. Filled in by
    /// [`crate::app::RustwardenBuilder::register_protected_prefix`] (and
    /// the workspace defaults `/home`, `/profile`, …). Companion crates
    /// such as `rustwarden-fediverse` register their own prefixes
    /// (`/fediverse/consent`, `/fediverse/invite`) via the builder before
    /// the app boots.
    #[deprecated(
        since = "1.2.0",
        note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; the per-request `AuthConfig::from_env()` fallback ignores builder overrides (`login_path`, `auth_failure_mode`, `headless`). Removal no earlier than 1.5.0, effective in the next major."
    )]
    pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self {
        Self {
            inner,
            db,
            protected_prefixes,
            auth_config: None,
        }
    }

    /// Create an `AuthService` that carries its own resolved
    /// [`AuthConfig`] — the supported constructor since RFC 0042.
    ///
    /// The config is captured **once at construction**, the same pattern
    /// `security_headers_middleware` uses in
    /// [`crate::app::RustwardenBuilder::build`]. Consequences:
    ///
    /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`)
    ///   are honoured regardless of tower layer order. A middleware layered
    ///   *outside* `Extension(Arc<AuthConfig>)` can never observe that
    ///   extension, which is what made [`AuthService::new`] fall back to
    ///   [`AuthConfig::from_env`] and discard those overrides.
    /// - The captured config takes precedence over any
    ///   `Extension(Arc<AuthConfig>)` present in the request.
    /// - On every request the middleware inserts the captured `Arc` into the
    ///   request extensions, so downstream extractors (`AuthUser`,
    ///   `auth_required`, `admin_required`) observe the same instance even in
    ///   hand-composed tower stacks that have no `Extension` layer of their
    ///   own. In the canonical wiring the inner `Extension` layer re-inserts
    ///   the very same `Arc`, which is a no-op.
    ///
    /// `protected_prefixes` has the same meaning as in
    /// [`AuthService::new`].
    ///
    /// ```no_run
    /// # use std::sync::Arc;
    /// # use rustwarden_core::config::auth::AuthConfig;
    /// # use rustwarden_core::middleware::auth_service::AuthService;
    /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> {
    /// let auth_config = Arc::new(AuthConfig::from_env()?);
    /// let prefixes = Arc::new(vec!["/api/private".to_string()]);
    /// let app = router.layer(tower::layer::layer_fn(move |inner| {
    ///     AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone())
    /// }));
    /// # let _ = app; Ok(()) }
    /// ```
    pub fn with_auth_config(
        inner: S,
        db: DatabaseConnection,
        protected_prefixes: Arc<Vec<String>>,
        auth_config: Arc<AuthConfig>,
    ) -> Self {
        Self {
            inner,
            db,
            protected_prefixes,
            auth_config: Some(auth_config),
        }
    }
}

/// Emitted at most once per process when the deprecated resolution path
/// reaches [`AuthConfig::from_env`] (RFC 0042, OQ5).
static DEPRECATED_CONFIG_PATH_WARNING: Once = Once::new();

/// Resolve the [`AuthConfig`] a request must be authenticated against.
///
/// Authoritative config first; request extension then environment only on
/// the deprecated path. A free function so it can be called from inside the
/// `'static` boxed future returned by `call` (where `self` is unavailable).
pub(crate) fn resolve_auth_config(
    captured: Option<&Arc<AuthConfig>>,
    extensions: &axum::http::Extensions,
) -> Result<Arc<AuthConfig>, String> {
    if let Some(config) = captured {
        return Ok(config.clone());
    }
    if let Some(config) = extensions.get::<Arc<AuthConfig>>() {
        return Ok(config.clone());
    }
    let config = AuthConfig::from_env().map(Arc::new)?;
    // Warn only once the fallback actually succeeded, so the message never
    // precedes a `from_env failed` error for the same request.
    DEPRECATED_CONFIG_PATH_WARNING.call_once(|| {
        tracing::warn!(
            "AuthConfig resolved from the environment: the deprecated \
             `AuthService::new` / `AdminService::new` constructor path is in use, \
             so builder overrides (login_path, auth_failure_mode, headless) are \
             ignored. Switch to `with_auth_config(..)`. This warning is emitted \
             once per process."
        );
    });
    Ok(config)
}

impl<S, B> Service<Request<B>> for AuthService<S>
where
    S: Service<Request<B>, Response = Response> + Clone + Send + 'static,
    S::Future: Send + 'static,
    B: Send + 'static,
{
    type Response = Response;
    type Error = S::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, mut req: Request<B>) -> Self::Future {
        let uri = req.uri().clone();
        let path = uri.path().to_owned();

        // Seed the captured config into the request extensions so downstream
        // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve
        // the exact same instance. In the canonical wiring the later
        // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which
        // is a no-op; seeding only matters for hand-composed tower stacks.
        if let Some(config) = &self.auth_config {
            req.extensions_mut().insert(config.clone());
        }
        let captured = self.auth_config.clone();

        // NOTE on `/api/v1/teams/*` : *not* protected here on purpose.
        // The canonical admin path is the UI handler
        // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite`
        // which is protected via `/admin/*` and passes `claims.sub` as
        // inviter directly. The `/api/v1/teams/*` route remains a
        // back-door consumed by E2E tests + future API integrators ;
        // its `invite()` handler accepts an `Option<Extension<Claims>>`
        // and falls back to `system_actor_user_id()` when no JWT is
        // present. A future RFC will gate this route once external
        // consumers are wired through a per-route auth extractor
        // independent of the UI middleware.
        let is_protected = self
            .protected_prefixes
            .iter()
            .any(|prefix| path.starts_with(prefix.as_str()));

        if is_protected {
            let db = self.db.clone();
            let mut inner = self.inner.clone();

            tracing::debug!("AuthService: intercepting protected route: {}", path);

            Box::pin(async move {
                let (parts, body) = req.into_parts();

                use crate::auth::jwt::JwtService;
                use crate::services::jwt_blacklist_service::JwtBlacklistService;

                // Resolve AuthConfig up-front so every authentication-failure
                // branch below can honour `login_path` / `auth_failure_mode`.
                let auth_config = match resolve_auth_config(captured.as_ref(), &parts.extensions) {
                    Ok(config) => config,
                    Err(err) => {
                        tracing::error!(
                            "AuthService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}"
                        );
                        return Ok((
                            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
                            "Internal server error",
                        )
                            .into_response());
                    }
                };

                let jwt_token =
                    match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) {
                        Ok(token) => {
                            tracing::debug!("AuthService: JWT token found");
                            token
                        }
                        Err(_) => {
                            tracing::warn!("AuthService: No JWT token found");
                            return Ok(auth_failure_response(&auth_config, &uri));
                        }
                    };

                match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await {
                    Ok(true) | Err(_) => {
                        tracing::warn!("AuthService: JWT token is blacklisted");
                        return Ok(auth_failure_response(&auth_config, &uri));
                    }
                    Ok(false) => {}
                }

                let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
                    Ok(claims) => claims,
                    Err(_) => {
                        tracing::warn!("AuthService: Invalid JWT token");
                        return Ok(auth_failure_response(&auth_config, &uri));
                    }
                };

                // F10 (RFC 0018) — refuse remote JWTs whose backing
                // `remote_identities` row was disabled (kill_domain,
                // auto-kill, manual revoke). The check delegates to a
                // `RemoteIdentityRevocationChecker` injected by
                // `rustwarden-fediverse` as an Axum `Extension`. When the
                // federation crate isn't linked the extension is absent
                // and the branch is skipped — which is correct, because
                // remote JWT issuance is also gated by that crate.
                if claims.is_remote() {
                    if let Some(rid) = claims.remote_identity_id() {
                        if let Some(checker) = parts.extensions.get::<std::sync::Arc<
                            dyn crate::middleware::revocation::RemoteIdentityRevocationChecker,
                        >>() {
                            if checker.is_revoked(rid).await {
                                tracing::warn!(
                                    "AuthService: remote_identity {} is revoked, rejecting JWT",
                                    rid
                                );
                                return Ok(auth_failure_response(&auth_config, &uri));
                            }
                        }
                    }
                }

                tracing::debug!("AuthService: User {} accessing: {}", claims.sub, uri);

                {
                    use crate::middleware::update_last_login::extract_client_ip;
                    use crate::models::user;
                    use chrono::Utc;
                    use sea_orm::{ActiveModelTrait, EntityTrait, Set};

                    let user_id = claims.sub;
                    let headers = &parts.headers;
                    let client_ip = extract_client_ip(headers);

                    let lookup = user::Entity::find_by_id(user_id).one(&db).await;
                    match map_user_lookup(lookup.as_ref().map(|row| row.as_ref())) {
                        UserLookup::Unavailable => {
                            if let Err(e) = &lookup {
                                tracing::error!(
                                    %user_id,
                                    error = %e,
                                    "user lookup failed (fail-closed)"
                                );
                            }
                            return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error")
                                .into_response());
                        }
                        UserLookup::Missing => {
                            return Ok(auth_failure_response(&auth_config, &uri));
                        }
                        UserLookup::Found => {
                            let Ok(Some(user_model)) = lookup else {
                                return Ok((
                                    StatusCode::SERVICE_UNAVAILABLE,
                                    "Internal server error",
                                )
                                    .into_response());
                            };

                            let must_change = user_model.must_change_password;

                            let mut user_active: user::ActiveModel = user_model.into();
                            user_active.last_login_at = Set(Some(Utc::now()));
                            if let Some(ip) = client_ip {
                                user_active.last_login_ip = Set(Some(ip));
                            }

                            if let Err(e) = user_active.update(&db).await {
                                tracing::warn!(
                                    "Failed to update last_login for user {}: {}",
                                    user_id,
                                    e
                                );
                            } else {
                                tracing::debug!("Updated last_login for user {}", user_id);
                            }

                            if must_change && !path.starts_with("/settings/password") {
                                tracing::info!(
                                    "User {} must change password, redirecting from {}",
                                    user_id,
                                    path
                                );
                                return Ok(Redirect::to("/settings/password?must_change=true")
                                    .into_response());
                            }

                            // Check if 2FA is forced globally and user hasn't set it up
                            if !path.starts_with("/settings/security")
                                && !path.starts_with("/api/v1/auth")
                            {
                                let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
                                    &db,
                                    &["totp_force_enabled"],
                                    false,
                                )
                                .await
                                .unwrap_or(false);

                                if totp_force_enabled {
                                    let totp_result = crate::services::totp_service::TotpService::is_totp_enabled(
                                        &db, user_id,
                                    )
                                    .await;
                                    if let Err(ref e) = totp_result {
                                        tracing::error!(
                                            %user_id,
                                            error = %e,
                                            "TOTP status lookup failed (fail-closed)"
                                        );
                                    }
                                    match totp_force_gate(totp_result) {
                                        TotpGate::Allow => {}
                                        TotpGate::RedirectSetup => {
                                            tracing::info!(
                                                "User {} must setup 2FA, redirecting from {}",
                                                user_id,
                                                path
                                            );
                                            return Ok(Redirect::to(
                                                "/settings/security?force_2fa=true",
                                            )
                                            .into_response());
                                        }
                                        TotpGate::Unavailable => {
                                            return Ok((
                                                StatusCode::SERVICE_UNAVAILABLE,
                                                "Internal server error",
                                            )
                                                .into_response());
                                        }
                                    }
                                }
                            }
                        }
                    }
                }

                let mut parts_with_claims = parts;
                parts_with_claims.extensions.insert(claims.clone());

                if let Ok(claims_json) = serde_json::to_string(&claims) {
                    parts_with_claims.extensions.insert(claims_json);
                }

                tracing::debug!(
                    "AuthService: Claims inserted into extensions for user {}",
                    claims.sub
                );
                let req_with_claims = Request::from_parts(parts_with_claims, body);

                inner.call(req_with_claims).await
            })
        } else {
            // SEC-H9: For non-protected routes, still try to extract and propagate
            // JWT claims into extensions so that handlers/templates can detect the
            // logged-in user (optional auth).
            let db = self.db.clone();
            let mut inner = self.inner.clone();
            Box::pin(async move {
                let (mut parts, body) = req.into_parts();

                if let Ok(jwt_token) =
                    crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts)
                {
                    use crate::auth::jwt::JwtService;
                    use crate::services::jwt_blacklist_service::JwtBlacklistService;

                    let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token)
                        .await
                        .unwrap_or(true);

                    if !is_blacklisted {
                        // RFC 0031 / RFC 0042: mirror the protected branch
                        // resolution so routes outside `protected_prefixes`
                        // still see injected claims, whatever the tower
                        // ordering of the `Extension(Arc<AuthConfig>)` layer.
                        // Without it, `claims` was silently `None` for all
                        // optional-auth routes.
                        let auth_config =
                            match resolve_auth_config(captured.as_ref(), &parts.extensions) {
                                Ok(config) => Some(config),
                                Err(e) => {
                                    tracing::error!(
                                        "AuthService optional_auth: AuthConfig fallback failed: {e}"
                                    );
                                    None
                                }
                            };

                        if let Some(auth_config) = auth_config {
                            if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token)
                            {
                                parts.extensions.insert(claims.clone());
                                if let Ok(json) = serde_json::to_string(&claims) {
                                    parts.extensions.insert(json);
                                }
                            }
                        }
                    }
                }

                let req = Request::from_parts(parts, body);
                inner.call(req).await
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{
        AuthService, TotpGate, UserLookup, auth_failure_response, map_user_lookup,
        sanitize_redirect, totp_force_gate,
    };
    use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
    use axum::body::Body;
    use axum::http::{Request as HttpRequest, StatusCode, Uri, header::LOCATION};
    use axum::response::Response;
    use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value};
    use std::collections::BTreeMap;
    use std::sync::Arc;
    use tower::ServiceExt; // for `oneshot`

    /// Mock connection that answers no query: any DB access is a test failure
    /// (its transaction log is asserted empty by the "no token" tests).
    fn mock_db() -> DatabaseConnection {
        MockDatabase::new(DatabaseBackend::Postgres).into_connection()
    }

    /// Row shape SeaORM 2.0 expects for `Select::count()`:
    /// `SELECT COUNT(*) AS num_items FROM (..) AS sub_query`.
    fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> {
        BTreeMap::from([("num_items", Value::BigInt(Some(n)))])
    }

    /// Mock connection whose single query result is the JWT-blacklist COUNT.
    fn mock_db_with_blacklist_count(n: i64) -> DatabaseConnection {
        MockDatabase::new(DatabaseBackend::Postgres)
            .append_query_results([vec![blacklist_count(n)]])
            .into_connection()
    }

    /// Number of SQL statements the mock connection actually received.
    fn statement_count(db: DatabaseConnection) -> usize {
        db.into_transaction_log()
            .iter()
            .map(|txn| txn.statements().len())
            .sum()
    }

    /// Router wrapped with `AuthService::with_auth_config` and **no**
    /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only
    /// source of truth, exactly as RFC 0042 requires.
    fn auth_router(db: DatabaseConnection, prefixes: &[&str], cfg: AuthConfig) -> axum::Router {
        let prefixes = Arc::new(
            prefixes
                .iter()
                .map(|p| (*p).to_string())
                .collect::<Vec<_>>(),
        );
        let cfg = Arc::new(cfg);
        axum::Router::new()
            .route("/private", axum::routing::get(|| async { "private" }))
            .route(
                "/public",
                axum::routing::get(
                    |axum::Extension(cfg): axum::Extension<Arc<AuthConfig>>| async move {
                        cfg.login_path.clone()
                    },
                ),
            )
            .layer(tower::layer::layer_fn(move |inner| {
                AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), cfg.clone())
            }))
    }

    async fn send(router: axum::Router, uri: &str, bearer: Option<&str>) -> Response {
        let mut builder = HttpRequest::builder().uri(uri);
        if let Some(token) = bearer {
            builder = builder.header("authorization", format!("Bearer {token}"));
        }
        let req = builder.body(Body::empty()).expect("request");
        router.oneshot(req).await.expect("oneshot")
    }

    async fn body_string(resp: Response) -> String {
        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
            .await
            .expect("body");
        String::from_utf8(bytes.to_vec()).expect("utf-8 body")
    }

    fn location(resp: &Response) -> Option<&str> {
        resp.headers().get(LOCATION).and_then(|v| v.to_str().ok())
    }

    /// T1 — protected prefix, no credentials, `Json401`: the captured config
    /// drives the 401 JSON response and no DB round-trip happens at all.
    #[tokio::test]
    async fn protected_route_without_jwt_returns_json_401_without_touching_the_database() {
        let db = mock_db();
        let router = auth_router(
            db.clone(),
            &["/private"],
            test_config(AuthFailureMode::Json401, "/login"),
        );

        let resp = send(router, "/private", None).await;

        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
        assert!(location(&resp).is_none());
        let body = body_string(resp).await;
        assert!(body.contains("unauthorized"), "unexpected body: {body}");
        assert_eq!(
            statement_count(db),
            0,
            "a missing token must short-circuit before any query"
        );
    }

    /// T2 — protected prefix, no credentials, `Redirect`: the captured
    /// `login_path` override is honoured even though no `Extension` layer
    /// exists and no environment variable is set.
    #[tokio::test]
    async fn protected_route_redirect_mode_uses_the_captured_login_path() {
        let router = auth_router(
            mock_db(),
            &["/private"],
            test_config(AuthFailureMode::Redirect, "/auth/signin"),
        );

        let resp = send(router, "/private", None).await;

        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
        assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate"));
    }

    /// T3 — the captured config is seeded into the request extensions, so a
    /// handler on a non-protected route can extract it although the test
    /// stack declares no `Extension` layer at all.
    #[tokio::test]
    async fn captured_config_is_seeded_into_the_request_extensions() {
        let router = auth_router(
            mock_db(),
            &["/private"],
            test_config(AuthFailureMode::Json401, "/auth/signin"),
        );

        let resp = send(router, "/public", None).await;

        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(body_string(resp).await, "/auth/signin");
    }

    /// T4 — a JWT signed with a foreign secret must be rejected by
    /// `JwtService::validate_token` against the *captured* config. The
    /// blacklist COUNT is mocked to 0 so the flow actually reaches the
    /// signature check, and it is the only statement the DB sees.
    #[tokio::test]
    async fn protected_route_rejects_jwt_signed_with_another_secret() {
        use crate::auth::jwt::JwtService;

        let cfg = test_config(AuthFailureMode::Json401, "/login");
        let mut foreign_cfg = test_config(AuthFailureMode::Json401, "/login");
        foreign_cfg.jwt_secret = "fedcba9876543210fedcba9876543210".to_string();
        let foreign_token = JwtService::generate_token(
            &foreign_cfg,
            uuid::Uuid::new_v4(),
            "intruder",
            Vec::new(),
            Vec::new(),
        )
        .expect("foreign token");
        assert!(
            JwtService::validate_token(&cfg, &foreign_token).is_err(),
            "the token must not verify against the captured config"
        );

        let db = mock_db_with_blacklist_count(0);
        let router = auth_router(db.clone(), &["/private"], cfg);

        let resp = send(router, "/private", Some(&foreign_token)).await;

        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
        assert_eq!(
            statement_count(db),
            1,
            "only the JWT-blacklist COUNT should reach the database"
        );
    }

    /// T5 — a JWT that *is* valid for the captured config is still refused
    /// when the blacklist COUNT reports it as revoked.
    #[tokio::test]
    async fn protected_route_rejects_blacklisted_but_valid_jwt() {
        use crate::auth::jwt::JwtService;

        let cfg = test_config(AuthFailureMode::Json401, "/login");
        let token = JwtService::generate_token(
            &cfg,
            uuid::Uuid::new_v4(),
            "alice",
            vec!["user".to_string()],
            Vec::new(),
        )
        .expect("token");
        assert!(
            JwtService::validate_token(&cfg, &token).is_ok(),
            "the token must verify against the captured config"
        );

        let db = mock_db_with_blacklist_count(1);
        let router = auth_router(db.clone(), &["/private"], cfg);

        let resp = send(router, "/private", Some(&token)).await;

        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
        let body = body_string(resp).await;
        assert!(body.contains("unauthorized"), "unexpected body: {body}");
        assert_eq!(
            statement_count(db),
            1,
            "the blacklist COUNT must short-circuit before the user lookup"
        );
    }

    fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig {
        AuthConfig {
            jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
            jwt_expiration_minutes: 15,
            jwt_issuer: "test".to_string(),
            refresh_token_expiration_days: 7,
            remember_me_expiration_days: 30,
            session_timeout_minutes: 30,
            session_backend: SessionBackend::Memory,
            redis_url: None,
            rate_limit_login_per_minute: 5,
            rate_limit_refresh_per_minute: 10,
            rate_limit_general_per_minute: 100,
            cookie_secure: false,
            cookie_same_site: "Strict".to_string(),
            login_path: login_path.to_string(),
            auth_failure_mode: mode,
        }
    }

    #[test]
    fn sanitize_redirect_preserves_default_login_path() {
        assert_eq!(
            sanitize_redirect("/settings", "/login"),
            "/login?redirect=%2Fsettings"
        );
    }

    #[test]
    fn sanitize_redirect_honors_custom_login_path() {
        assert_eq!(
            sanitize_redirect("/settings", "/auth/signin"),
            "/auth/signin?redirect=%2Fsettings"
        );
    }

    #[test]
    fn sanitize_redirect_reduces_unsafe_target_to_bare_login_path() {
        assert_eq!(
            sanitize_redirect("//evil.example", "/auth/signin"),
            "/auth/signin"
        );
    }

    #[test]
    fn sanitize_redirect_rejects_backslash_trick() {
        let out = sanitize_redirect("/\\evil.example", "/login");
        assert_eq!(out, "/login");
    }

    #[test]
    fn auth_failure_redirect_mode_points_to_configured_login_path() {
        let uri: Uri = "/settings".parse().unwrap();
        let resp = auth_failure_response(
            &test_config(AuthFailureMode::Redirect, "/auth/signin"),
            &uri,
        );
        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
        let location = resp
            .headers()
            .get(LOCATION)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default();
        assert!(
            location.starts_with("/auth/signin"),
            "unexpected Location header: {location}"
        );
    }

    #[test]
    fn auth_failure_json401_mode_returns_401_without_location() {
        let uri: Uri = "/settings".parse().unwrap();
        let resp = auth_failure_response(&test_config(AuthFailureMode::Json401, "/login"), &uri);
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
        assert!(resp.headers().get(LOCATION).is_none());
    }

    #[test]
    fn map_user_lookup_found_when_row_present() {
        let result: Result<Option<()>, &str> = Ok(Some(()));
        assert_eq!(map_user_lookup(result), UserLookup::Found);
    }

    #[test]
    fn map_user_lookup_missing_when_no_row() {
        let result: Result<Option<()>, &str> = Ok(None);
        assert_eq!(map_user_lookup(result), UserLookup::Missing);
    }

    #[test]
    fn map_user_lookup_unavailable_on_error() {
        let result: Result<Option<()>, &str> = Err("db down");
        assert_eq!(map_user_lookup(result), UserLookup::Unavailable);
    }

    #[test]
    fn totp_force_gate_allows_when_enabled() {
        let result: Result<bool, &str> = Ok(true);
        assert_eq!(totp_force_gate(result), TotpGate::Allow);
    }

    #[test]
    fn totp_force_gate_redirects_when_not_enrolled() {
        let result: Result<bool, &str> = Ok(false);
        assert_eq!(totp_force_gate(result), TotpGate::RedirectSetup);
    }

    #[test]
    fn totp_force_gate_unavailable_on_error() {
        let result: Result<bool, &str> = Err("db down");
        assert_eq!(totp_force_gate(result), TotpGate::Unavailable);
    }
}