root_handler.rs 176 lignes · 6443 octets
/// Root Handler
/// Feature 004: UI Post-Auth
///
/// Handles the root path `/` with smart redirection based on authentication status.
use std::sync::Arc;

use axum::{
    extract::Request,
    response::{IntoResponse, Redirect},
};
use rustwarden_core::config::auth::AuthConfig;

/// Handler for GET `/` - Root path with smart redirection
///
/// Redirects based on authentication status:
/// - If authenticated → redirects to `/home`
/// - If not authenticated → redirects to the configured login path
///   ([`AuthConfig::login_path`], default `/login`)
///
/// This provides a seamless entry point for users.
///
/// The [`AuthConfig`] is read from the request extensions, where
/// `RustwardenBuilder::build()` injects the configuration resolved at
/// startup (including builder overrides such as `login_path`, RFC 0042).
/// `AuthConfig::from_env()` is only a defence for stacks that do not pose
/// the extension.
///
/// # Arguments
///
/// * `request` - HTTP request to check for JWT token
///
/// # Returns
///
/// * `303 See Other` redirect to `/home` or to the login path
///
/// # Example
///
/// ```bash
/// # Without authentication
/// curl -I http://localhost:3000/
/// # → 303 Redirect to /login
///
/// # With valid JWT token
/// curl -I http://localhost:3000/ -H "Cookie: jwt_token=..."
/// # → 303 Redirect to /home
/// ```
pub async fn root_redirect(request: Request) -> impl IntoResponse {
    use rustwarden_core::auth::jwt::JwtService;

    // Prefer the config resolved at build time; keep `from_env()` as a
    // defence for stacks that do not pose the extension.
    let auth_config = match request.extensions().get::<Arc<AuthConfig>>().cloned() {
        Some(config) => config,
        None => match AuthConfig::from_env() {
            Ok(config) => Arc::new(config),
            Err(_) => {
                // If config can't be loaded, redirect to login as fallback
                tracing::warn!("root_redirect: failed to load auth config, redirecting to /login");
                return Redirect::to("/login");
            }
        },
    };
    let login_path = auth_config.login_path.as_str();

    // Try to extract JWT token from request
    match rustwarden_core::auth::jwt_extractor::extract_jwt_from_request(&request) {
        Ok(jwt_token) => {
            // Validate the token without checking the blacklist: it is
            // re-validated on protected routes anyway.
            match JwtService::validate_token(&auth_config, &jwt_token) {
                Ok(_claims) => {
                    tracing::debug!("root_redirect: valid JWT token found, redirecting to /home");
                    Redirect::to("/home")
                }
                Err(_) => {
                    tracing::debug!(
                        "root_redirect: invalid JWT token, redirecting to {login_path}"
                    );
                    Redirect::to(login_path)
                }
            }
        }
        Err(_) => {
            tracing::debug!("root_redirect: no JWT token found, redirecting to {login_path}");
            Redirect::to(login_path)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::root_redirect;
    use axum::body::Body;
    use axum::http::{Request as HttpRequest, StatusCode, header::LOCATION};
    use axum::{Extension, Router, routing::get};
    use rustwarden_core::auth::jwt::JwtService;
    use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
    use std::sync::Arc;
    use tower::ServiceExt; // for `oneshot`

    /// A `login_path` that cannot come from the environment of the test
    /// process, so a redirect to it proves the extension was used.
    const INJECTED_LOGIN_PATH: &str = "/auth/signin";

    fn injected_config() -> 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: INJECTED_LOGIN_PATH.to_string(),
            auth_failure_mode: AuthFailureMode::Redirect,
        }
    }

    fn router_with_injected_config() -> Router {
        Router::new()
            .route("/", get(root_redirect))
            .layer(Extension(Arc::new(injected_config())))
    }

    async fn location_of(router: Router, bearer: Option<&str>) -> (StatusCode, String) {
        let mut builder = HttpRequest::builder().uri("/");
        if let Some(token) = bearer {
            builder = builder.header("authorization", format!("Bearer {token}"));
        }
        let req = builder.body(Body::empty()).expect("request");
        let resp = router.oneshot(req).await.expect("oneshot");
        let location = resp
            .headers()
            .get(LOCATION)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_string();
        (resp.status(), location)
    }

    #[tokio::test]
    async fn anonymous_visitor_is_sent_to_the_injected_login_path() {
        let (status, location) = location_of(router_with_injected_config(), None).await;
        assert_eq!(status, StatusCode::SEE_OTHER);
        assert_eq!(location, INJECTED_LOGIN_PATH);
    }

    #[tokio::test]
    async fn invalid_token_is_sent_to_the_injected_login_path() {
        let (status, location) =
            location_of(router_with_injected_config(), Some("not-a-jwt")).await;
        assert_eq!(status, StatusCode::SEE_OTHER);
        assert_eq!(location, INJECTED_LOGIN_PATH);
    }

    #[tokio::test]
    async fn valid_token_signed_with_the_injected_secret_goes_home() {
        let token = JwtService::generate_token(
            &injected_config(),
            uuid::Uuid::new_v4(),
            "alice",
            vec!["user".to_string()],
            Vec::new(),
        )
        .expect("token");
        let (status, location) = location_of(router_with_injected_config(), Some(&token)).await;
        assert_eq!(status, StatusCode::SEE_OTHER);
        assert_eq!(location, "/home");
    }
}