r-token 1.2.0

A simple and efficient token generation library for Rust, ideal for API authentication and session management.
Documentation
#[cfg(feature = "redis")]
mod redis_tests {
    use r_token::RTokenRedisManager;
    use redis::AsyncCommands;
    use std::net::TcpListener;
    use std::process::{Child, Command, Stdio};
    use std::sync::OnceLock;

    struct RedisTestServer {
        child: Child,
        url: String,
    }

    impl Drop for RedisTestServer {
        fn drop(&mut self) {
            let _ = self.child.kill();
            let _ = self.child.wait();
        }
    }

    static REDIS_TEST_SERVER: OnceLock<RedisTestServer> = OnceLock::new();

    fn free_port() -> u16 {
        TcpListener::bind(("127.0.0.1", 0))
            .and_then(|listener| listener.local_addr())
            .map(|addr| addr.port())
            .expect("get free port failed")
    }

    fn spawn_redis_server() -> RedisTestServer {
        let port = free_port();
        let mut child = Command::new("redis-server")
            .arg("--port")
            .arg(port.to_string())
            .arg("--save")
            .arg("")
            .arg("--appendonly")
            .arg("no")
            .arg("--protected-mode")
            .arg("no")
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn redis-server failed");

        if let Ok(Some(status)) = child.try_wait() {
            panic!("redis-server exited early: {status}");
        }

        RedisTestServer {
            child,
            url: format!("redis://127.0.0.1:{port}/"),
        }
    }

    async fn wait_redis_ready(url: &str) {
        let client = redis::Client::open(url).expect("redis client open failed");

        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3);
        loop {
            if tokio::time::Instant::now() >= deadline {
                panic!("redis-server not ready at {url}");
            }

            if let Ok(mut connection) = client.get_connection_manager().await
                && connection.ping::<String>().await.as_deref() == Ok("PONG")
            {
                return;
            }

            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        }
    }

    async fn test_redis_url() -> String {
        if let Ok(url) = std::env::var("REDIS_URL") {
            return url;
        }

        let server = REDIS_TEST_SERVER.get_or_init(spawn_redis_server);
        wait_redis_ready(&server.url).await;
        server.url.clone()
    }

    fn unique_prefix(test_name: &str) -> String {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        format!("r_token:test:{}:{}:", test_name, nanos)
    }

    #[tokio::test]
    async fn redis_login_validate_roundtrip() {
        let redis_url = test_redis_url().await;
        let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("roundtrip"))
            .await
            .expect("redis connect failed");

        let token = manager.login("alice", 60).await.expect("login failed");

        let user_id = manager.validate(&token).await.expect("validate failed");

        assert_eq!(user_id.as_deref(), Some("alice"));
    }

    #[tokio::test]
    async fn redis_logout_revokes_token() {
        let redis_url = test_redis_url().await;
        let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("logout"))
            .await
            .expect("redis connect failed");

        let token = manager.login("bob", 60).await.expect("login failed");

        manager.logout(&token).await.expect("logout failed");

        let user_id = manager.validate(&token).await.expect("validate failed");

        assert!(user_id.is_none());
    }

    #[tokio::test]
    async fn redis_ttl_expires_token() {
        let redis_url = test_redis_url().await;
        let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("ttl"))
            .await
            .expect("redis connect failed");

        let token = manager.login("carol", 1).await.expect("login failed");

        tokio::time::sleep(std::time::Duration::from_secs(2)).await;

        let user_id = manager.validate(&token).await.expect("validate failed");

        assert!(user_id.is_none());
    }

    #[tokio::test]
    async fn redis_ttl_and_renew_and_rotate_work() {
        let redis_url = test_redis_url().await;
        let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("renew_rotate"))
            .await
            .expect("redis connect failed");

        let token = manager.login("alice", 60).await.expect("login failed");

        let ttl = manager
            .ttl_seconds(&token)
            .await
            .expect("ttl_seconds failed")
            .expect("missing token ttl");
        assert!(ttl > 0 || ttl == -1);

        let renewed = manager.renew(&token, 120).await.expect("renew failed");
        assert!(renewed);

        let new_token = manager
            .rotate(&token, 120)
            .await
            .expect("rotate failed")
            .expect("missing token on rotate");

        let user_id = manager.validate(&token).await.expect("validate failed");
        assert!(user_id.is_none());

        let user_id = manager.validate(&new_token).await.expect("validate failed");
        assert_eq!(user_id.as_deref(), Some("alice"));
    }

    #[cfg(feature = "actix")]
    mod actix_redis_extractor_tests {
        use super::*;
        use actix_web::cookie::Cookie;
        use actix_web::{App, HttpResponse, get, test as actix_test, web};
        use r_token::RRedisUser;

        #[actix_web::test]
        async fn redis_extractor_accepts_authorization_header() {
            #[get("/protected")]
            async fn protected(user: RRedisUser) -> impl actix_web::Responder {
                HttpResponse::Ok().body(user.id)
            }

            let redis_url = test_redis_url().await;
            let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("actix_header"))
                .await
                .expect("redis connect failed");

            let token = manager.login("alice", 60).await.expect("login failed");

            let app = actix_test::init_service(
                App::new()
                    .app_data(web::Data::new(manager))
                    .service(protected),
            )
            .await;

            let req = actix_test::TestRequest::get()
                .uri("/protected")
                .insert_header(("Authorization", token.as_str()))
                .to_request();

            let resp = actix_test::call_service(&app, req).await;
            assert_eq!(resp.status(), 200);
            let body = actix_test::read_body(resp).await;
            assert_eq!(body, "alice");
        }

        #[actix_web::test]
        async fn redis_extractor_accepts_cookie_token() {
            #[get("/protected")]
            async fn protected(user: RRedisUser) -> impl actix_web::Responder {
                HttpResponse::Ok().body(user.id)
            }

            let redis_url = test_redis_url().await;
            let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("actix_cookie"))
                .await
                .expect("redis connect failed");

            let token = manager.login("bob", 60).await.expect("login failed");

            let app = actix_test::init_service(
                App::new()
                    .app_data(web::Data::new(manager))
                    .service(protected),
            )
            .await;

            let req = actix_test::TestRequest::get()
                .uri("/protected")
                .cookie(Cookie::new(r_token::TOKEN_COOKIE_NAME, token))
                .to_request();

            let resp = actix_test::call_service(&app, req).await;
            assert_eq!(resp.status(), 200);
            let body = actix_test::read_body(resp).await;
            assert_eq!(body, "bob");
        }

        #[actix_web::test]
        async fn authorization_header_takes_precedence_over_cookie() {
            #[get("/protected")]
            async fn protected(user: RRedisUser) -> impl actix_web::Responder {
                HttpResponse::Ok().body(user.id)
            }

            let redis_url = test_redis_url().await;
            let manager =
                RTokenRedisManager::connect(&redis_url, unique_prefix("actix_precedence"))
                    .await
                    .expect("redis connect failed");

            let token = manager.login("carol", 60).await.expect("login failed");

            let app = actix_test::init_service(
                App::new()
                    .app_data(web::Data::new(manager))
                    .service(protected),
            )
            .await;

            let req = actix_test::TestRequest::get()
                .uri("/protected")
                .insert_header(("Authorization", "invalid-token-xyz"))
                .cookie(Cookie::new(r_token::TOKEN_COOKIE_NAME, token))
                .to_request();

            let resp = actix_test::call_service(&app, req).await;
            assert_eq!(resp.status(), 401);
        }

        #[actix_web::test]
        async fn token_source_config_cookie_first_can_override_header() {
            #[get("/protected")]
            async fn protected(user: RRedisUser) -> impl actix_web::Responder {
                HttpResponse::Ok().body(user.id)
            }

            let redis_url = test_redis_url().await;
            let manager = RTokenRedisManager::connect(&redis_url, unique_prefix("actix_cfg"))
                .await
                .expect("redis connect failed");

            let token = manager.login("dave", 60).await.expect("login failed");

            let cfg = r_token::TokenSourceConfig {
                priority: r_token::TokenSourcePriority::CookieFirst,
                header_names: vec!["Authorization".to_string()],
                cookie_names: vec![r_token::TOKEN_COOKIE_NAME.to_string()],
            };

            let app = actix_test::init_service(
                App::new()
                    .app_data(web::Data::new(manager))
                    .app_data(web::Data::new(cfg))
                    .service(protected),
            )
            .await;

            let req = actix_test::TestRequest::get()
                .uri("/protected")
                .insert_header(("Authorization", "invalid-token-xyz"))
                .cookie(Cookie::new(r_token::TOKEN_COOKIE_NAME, token))
                .to_request();

            let resp = actix_test::call_service(&app, req).await;
            assert_eq!(resp.status(), 200);
            let body = actix_test::read_body(resp).await;
            assert_eq!(body, "dave");
        }
    }
}