#![cfg(feature = "sqlite")] //! Authentication security tests (OWASP-oriented). use axum::{ body::Body, http::{Request, StatusCode, header}, }; use http_body_util::BodyExt; use serde_json::Value; use tower::ServiceExt; use nx9_auth::{ api, config::{Config, SecurityConfig}, db::models::Tenant, identity::{roles as identity_roles, users as identity_users}, state::AppState, }; fn test_security_config() -> SecurityConfig { SecurityConfig { session_ttl_hours: 24, session_absolute_ttl_days: 30, token_ttl_days: 365, argon2_memory: 4096, argon2_iterations: 1, argon2_parallelism: 1, } } async fn setup() -> (AppState, String) { let db_id = uuid::Uuid::new_v4().to_string(); let db_path = format!("target/test_authsec_{}.db", db_id); let pool = nx9_auth::db::create_pool(&db_path).await.unwrap(); nx9_auth::db::run_migrations(&pool).await.unwrap(); let provider: std::sync::Arc = std::sync::Arc::new(nx9_auth::db::provider::SqliteProvider::new(pool)); let mut config = Config { security: test_security_config(), ..Default::default() }; config.server.host = "127.0.0.1".into(); config.server.port = 8655; config.server.cookie_secure = false; config.server.production = false; let state = AppState::new(provider.clone(), config); let admin = identity_users::create_user( &provider, &test_security_config(), Tenant::DEFAULT_ID, "sec_admin", "super_secure_admin_passphrase_123", None, None, None, ) .await .unwrap(); identity_roles::assign_role(&provider, &admin.id, "admin", None, None, None) .await .unwrap(); (state, db_path) } #[tokio::test] async fn test_login_is_post_only() { let (state, db_path) = setup().await; let app = api::router::build(state); // 1. GET /api/v1/auth/login must return METHOD_NOT_ALLOWED (405). let res = app .clone() .oneshot( Request::builder() .method("GET") .uri("/api/v1/auth/login?username=sec_admin&password=super_secure_admin_passphrase_123") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED); // 2. GET /login?username=...&password=... must be sanitized with HTTP 303 See Other redirecting to /login without credentials. let res_spa = app .clone() .oneshot( Request::builder() .method("GET") .uri("/login?username=sec_admin&password=super_secure_admin_passphrase_123") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res_spa.status(), StatusCode::SEE_OTHER); assert_eq!(res_spa.headers().get(header::LOCATION).unwrap(), "/login"); // POST with JSON succeeds and returns access_token. let res = app .oneshot( Request::builder() .method("POST") .uri("/api/v1/auth/login") .header(header::CONTENT_TYPE, "application/json") .header(header::ACCEPT, "application/json") .body(Body::from( r#"{"username":"sec_admin","password":"super_secure_admin_passphrase_123"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = res.into_body().collect().await.unwrap().to_bytes(); let json: Value = serde_json::from_slice(&body).unwrap(); assert!(json.get("access_token").and_then(|v| v.as_str()).is_some()); assert!(json.get("refresh_token").and_then(|v| v.as_str()).is_some()); assert!(json.get("expires_in").and_then(|v| v.as_u64()).is_some()); assert_eq!( json.get("token_type").and_then(|v| v.as_str()), Some("Bearer") ); assert!(json.pointer("/user/username").and_then(|v| v.as_str()) == Some("sec_admin")); // Password must never appear in response let text = String::from_utf8_lossy(&body); assert!(!text.contains("super_secure_admin_passphrase_123")); let _ = std::fs::remove_file(db_path); } #[tokio::test] async fn test_login_invalid_credentials_non_enumerating() { let (state, db_path) = setup().await; let app = api::router::build(state); for body in [ r#"{"username":"no_such_user","password":"whatever_password_xx"}"#, r#"{"username":"sec_admin","password":"wrong_password_xx"}"#, ] { let res = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/auth/login") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::UNAUTHORIZED); let bytes = res.into_body().collect().await.unwrap().to_bytes(); let json: Value = serde_json::from_slice(&bytes).unwrap(); let err = json["error"].as_str().unwrap_or(""); assert_eq!(err, "Invalid username or password."); // Must not reveal which field failed assert!(!err.to_lowercase().contains("unknown user")); assert!(!err.to_lowercase().contains("incorrect password")); } let _ = std::fs::remove_file(db_path); } #[tokio::test] async fn test_login_bearer_access_token_works() { let (state, db_path) = setup().await; let app = api::router::build(state); let res = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/auth/login") .header(header::CONTENT_TYPE, "application/json") .body(Body::from( r#"{"username":"sec_admin","password":"super_secure_admin_passphrase_123"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let body = res.into_body().collect().await.unwrap().to_bytes(); let json: Value = serde_json::from_slice(&body).unwrap(); let token = json["access_token"].as_str().unwrap(); let res = app .oneshot( Request::builder() .method("GET") .uri("/api/v1/auth/me") .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let _ = std::fs::remove_file(db_path); } #[tokio::test] async fn test_security_headers_present() { let (state, db_path) = setup().await; let app = api::router::build(state); let res = app .oneshot( Request::builder() .method("GET") .uri("/health") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let h = res.headers(); assert!(h.get("x-content-type-options").is_some()); assert!(h.get("x-frame-options").is_some()); assert!(h.get("referrer-policy").is_some()); assert!(h.get("content-security-policy").is_some()); assert!(h.get("permissions-policy").is_some()); let _ = std::fs::remove_file(db_path); } #[tokio::test] async fn test_production_requires_cookie_secure() { let mut cfg = Config::default(); cfg.server.production = true; cfg.server.cookie_secure = false; assert!(cfg.server.validate_production_security().is_err()); cfg.server.cookie_secure = true; assert!(cfg.server.validate_production_security().is_ok()); } #[tokio::test] async fn test_argon2id_hash_format() { use nx9_auth::security::passwords; let cfg = test_security_config(); let hash = passwords::hash_password("super_secure_passphrase_123", &cfg).unwrap(); assert!(hash.starts_with("$argon2id$"), "hash={hash}"); assert!(passwords::verify_password("super_secure_passphrase_123", &hash).unwrap()); assert!(!passwords::verify_password("wrong", &hash).unwrap()); }