//! JWT Authentication Helper Module for E2E Tests //! //! Provides reusable authentication utilities for integration and E2E tests. //! This module centralizes JWT token generation and authenticated client creation //! to ensure consistency across test suites. //! //! Features: //! - Valid JWT token generation matching API Gateway validation //! - Authenticated gRPC client creation with proper metadata //! - Support for both MFA-enabled and MFA-disabled scenarios //! - Configurable user roles and permissions //! - Environment-based JWT secret management //! //! Usage: //! ```rust //! use common::auth_helpers::{create_test_jwt, create_authenticated_trading_client}; //! //! #[tokio::test] //! async fn test_authenticated_request() -> Result<()> { //! let mut client = create_authenticated_trading_client().await?; //! // Use client for authenticated gRPC calls //! Ok(()) //! } //! ``` /// Initialize test environment by loading .env file /// This runs BEFORE module initialization, ensuring JWT_SECRET is available #[ctor::ctor] fn init_test_env() { // Load .env file at module initialization time let _ = dotenvy::dotenv(); // Verify JWT_SECRET is available if std::env::var("JWT_SECRET").is_err() { eprintln!("WARNING: JWT_SECRET not found in .env file"); } } use anyhow::{Context, Result}; use chrono::{Duration, Utc}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde::{Deserialize, Serialize}; use tonic::{metadata::MetadataValue, Request, Status}; use uuid::Uuid; /// Default API Gateway address for testing /// /// Port 50051 = API Gateway external port (0.0.0.0:50051) pub const DEFAULT_API_GATEWAY_ADDR: &str = "http://localhost:50051"; /// Default test user ID pub const DEFAULT_TEST_USER_ID: &str = "test_trader_001"; /// Default test user role pub const DEFAULT_TEST_ROLE: &str = "trader"; /// JWT claims structure (matches API Gateway JwtClaims) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestJwtClaims { /// JWT ID for revocation tracking (MANDATORY for API Gateway) pub jti: String, /// Subject (user ID) pub sub: String, /// Issued at timestamp pub iat: u64, /// Expiration timestamp pub exp: u64, /// Issuer (must match API Gateway validation) pub iss: String, /// Audience (must match API Gateway validation) pub aud: String, /// User roles pub roles: Vec, /// Additional permissions pub permissions: Vec, /// Token type: "access" or "refresh" #[serde(default = "default_token_type")] pub token_type: String, /// Session ID for tracking related tokens pub session_id: Option, } fn default_token_type() -> String { "access".to_string() } /// Authentication configuration for test tokens #[derive(Debug, Clone)] pub struct TestAuthConfig { /// User ID for the test token pub user_id: String, /// User roles (e.g., "trader", "admin", "viewer") pub roles: Vec, /// User permissions (e.g., "trading.submit", "api.access") pub permissions: Vec, /// Token expiration duration (default: 1 hour) pub expiry_duration: Duration, /// Whether MFA is enabled for this user pub mfa_enabled: bool, /// Whether MFA has been verified (only relevant if mfa_enabled = true) pub mfa_verified: bool, } impl Default for TestAuthConfig { fn default() -> Self { Self { user_id: DEFAULT_TEST_USER_ID.to_string(), roles: vec![DEFAULT_TEST_ROLE.to_string()], permissions: vec![ "api.access".to_string(), "trading.submit".to_string(), "trading.view".to_string(), "trading.cancel".to_string(), ], expiry_duration: Duration::hours(1), mfa_enabled: false, mfa_verified: false, } } } impl TestAuthConfig { /// Create a new test auth config with default trader permissions pub fn trader() -> Self { Self::default() } /// Create a test auth config for an admin user pub fn admin() -> Self { Self { user_id: "test_admin_001".to_string(), roles: vec!["admin".to_string()], permissions: vec![ "api.access".to_string(), "trading.*".to_string(), "admin.*".to_string(), ], expiry_duration: Duration::hours(1), mfa_enabled: true, mfa_verified: true, } } /// Create a test auth config for a viewer (read-only) user pub fn viewer() -> Self { Self { user_id: "test_viewer_001".to_string(), roles: vec!["viewer".to_string()], permissions: vec!["api.access".to_string(), "trading.view".to_string()], expiry_duration: Duration::hours(1), mfa_enabled: false, mfa_verified: false, } } /// Create a test auth config with MFA enabled pub fn with_mfa_enabled(mut self) -> Self { self.mfa_enabled = true; self.mfa_verified = true; // Assume verified for tests unless explicitly set self } /// Set custom user ID pub fn with_user_id(mut self, user_id: impl Into) -> Self { self.user_id = user_id.into(); self } /// Set custom roles pub fn with_roles(mut self, roles: Vec) -> Self { self.roles = roles; self } /// Set custom permissions pub fn with_permissions(mut self, permissions: Vec) -> Self { self.permissions = permissions; self } /// Set custom expiry duration pub fn with_expiry(mut self, duration: Duration) -> Self { self.expiry_duration = duration; self } } /// Get JWT secret from environment (REQUIRED) /// /// WAVE 130: Fail-fast pattern - JWT_SECRET MUST be set in .env file /// /// This prevents configuration drift and ensures single source of truth /// /// # Panics /// /// Panics if JWT_SECRET environment variable is not set /// /// # Setup /// 1. Copy .env.example to .env /// 2. Set JWT_SECRET in .env file /// 3. Load .env before running tests (e.g., `source .env` or use dotenv) pub fn get_test_jwt_secret() -> String { // WAVE 149 Agent 411: Trim whitespace to match API Gateway behavior std::env::var("JWT_SECRET") .expect( "FATAL: JWT_SECRET must be set in .env file for E2E tests\n\ \n\ Setup:\n\ 1. Copy .env.example to .env\n\ 2. Set JWT_SECRET in .env file\n\ 3. Run: export $(cat .env | xargs)\n\ \n\ Example: JWT_SECRET=OvFLDUbIDak3CSCi5t6zKfsAp65cjTOJ85q9YE+TFY8b361DGg1gSTra2rW6mps3cWrRGQ/NXRA5uftUpMldvOaEHMMgfBs4JjVODDElREdvUFm0EttD1A==", ) .trim() .to_string() } /// Get test user ID (consistent across tests) pub fn get_test_user_id() -> String { DEFAULT_TEST_USER_ID.to_string() } /// Get API Gateway address from environment or use default pub fn get_api_addr() -> String { std::env::var("API_GATEWAY_ADDR").unwrap_or_else(|_| DEFAULT_API_GATEWAY_ADDR.to_string()) } /// Generate a valid JWT token for testing /// /// This function creates a JWT token that will pass API Gateway validation: /// - Issuer: "foxhunt-trading" (matches JwtConfig) /// /// - Audience: "trading-api" (matches JwtConfig) /// - Algorithm: HS256 (matches API Gateway) /// /// - Contains required claims: jti, sub, iat, exp, roles, permissions /// /// # Arguments /// * `config` - Authentication configuration (user ID, roles, permissions, etc.) /// /// # Returns /// * `Result` - JWT token string ready to use in Authorization header /// /// # Example /// ```rust /// let config = TestAuthConfig::trader(); /// let token = create_test_jwt(config)?; /// assert!(!token.is_empty()); /// ``` pub fn create_test_jwt(config: TestAuthConfig) -> Result { let now = Utc::now(); let claims = TestJwtClaims { jti: Uuid::new_v4().to_string(), // Required for revocation tracking sub: config.user_id, iat: now.timestamp() as u64, exp: (now + config.expiry_duration).timestamp() as u64, iss: "foxhunt-api".to_string(), // MUST match API Gateway JwtConfig aud: "foxhunt-services".to_string(), // MUST match API Gateway JwtConfig roles: config.roles, permissions: config.permissions, token_type: "access".to_string(), session_id: Some(Uuid::new_v4().to_string()), }; let jwt_secret = get_test_jwt_secret(); let token = encode( &Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(jwt_secret.as_ref()), ) .context("Failed to encode JWT token")?; Ok(token) } /// Generate a valid JWT token with default trader configuration /// /// Convenience function for the most common test case. /// /// # Example /// ```rust /// let token = create_default_test_jwt()?; /// ``` pub fn create_default_test_jwt() -> Result { create_test_jwt(TestAuthConfig::default()) } /// Generate an expired JWT token for testing token expiry handling /// /// # Example /// ```rust /// let expired_token = create_expired_test_jwt()?; /// // This token will fail API Gateway validation due to expiry /// ``` pub fn create_expired_test_jwt() -> Result { let config = TestAuthConfig::default().with_expiry(Duration::seconds(-3600)); create_test_jwt(config) } /// Generate a JWT token with invalid issuer for testing validation /// /// # Example /// ```rust /// let invalid_token = create_invalid_issuer_jwt()?; /// // This token will fail API Gateway validation due to wrong issuer /// ``` pub fn create_invalid_issuer_jwt() -> Result { let now = Utc::now(); let claims = TestJwtClaims { jti: Uuid::new_v4().to_string(), sub: DEFAULT_TEST_USER_ID.to_string(), iat: now.timestamp() as u64, exp: (now + Duration::hours(1)).timestamp() as u64, iss: "wrong-issuer".to_string(), // Invalid issuer aud: "foxhunt-services".to_string(), roles: vec![DEFAULT_TEST_ROLE.to_string()], permissions: vec!["api.access".to_string()], token_type: "access".to_string(), session_id: Some(Uuid::new_v4().to_string()), }; let jwt_secret = get_test_jwt_secret(); let token = encode( &Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(jwt_secret.as_ref()), ) .context("Failed to encode JWT token")?; Ok(token) } /// Create an authentication interceptor function /// /// This function creates an interceptor closure that automatically injects /// JWT token and user context into request metadata. /// /// # Arguments /// * `config` - Authentication configuration /// /// # Returns /// * Interceptor function that can be used with `Client::with_interceptor` /// /// # Example /// ```rust /// use trading::trading_service_client::TradingServiceClient; /// /// let interceptor = create_auth_interceptor(TestAuthConfig::trader())?; /// let channel = Channel::from_static("http://localhost:50051").connect().await?; /// let mut client = TradingServiceClient::with_interceptor(channel, interceptor); /// ``` /// /// # Errors /// Returns error if the operation fails pub fn create_auth_interceptor( config: TestAuthConfig, ) -> Result) -> Result, Status> + Clone> { let token = create_test_jwt(config.clone())?; let user_id = config.user_id.clone(); let roles_str = config.roles.join(","); let interceptor = move |mut req: Request<()>| -> Result, Status> { // Inject JWT token in Authorization header let token_value = format!("Bearer {}", token); let metadata_value = MetadataValue::try_from(token_value) .map_err(|e| Status::invalid_argument(format!("Invalid token format: {}", e)))?; req.metadata_mut().insert("authorization", metadata_value); // Inject user context metadata (expected by some services) let user_id_value = MetadataValue::try_from(user_id.as_str()) .map_err(|e| Status::invalid_argument(format!("Invalid user_id: {}", e)))?; req.metadata_mut().insert("x-user-id", user_id_value); let role_value = MetadataValue::try_from(roles_str.as_str()) .map_err(|e| Status::invalid_argument(format!("Invalid role: {}", e)))?; req.metadata_mut().insert("x-user-role", role_value); Ok(req) }; Ok(interceptor) } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_test_jwt_default() { let token = create_default_test_jwt().expect("Should create JWT token"); assert!(!token.is_empty()); assert!(token.contains(".")); // JWT has 3 parts separated by dots } #[test] fn test_create_test_jwt_trader() { let config = TestAuthConfig::trader(); let token = create_test_jwt(config).expect("Should create JWT token"); assert!(!token.is_empty()); } #[test] fn test_create_test_jwt_admin() { let config = TestAuthConfig::admin(); let token = create_test_jwt(config).expect("Should create JWT token"); assert!(!token.is_empty()); } #[test] fn test_create_test_jwt_viewer() { let config = TestAuthConfig::viewer(); let token = create_test_jwt(config).expect("Should create JWT token"); assert!(!token.is_empty()); } #[test] fn test_create_expired_jwt() { let token = create_expired_test_jwt().expect("Should create expired JWT token"); assert!(!token.is_empty()); // Decode to verify it's expired use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = false; // Disable expiry check to decode validation.set_issuer(&["foxhunt-api"]); validation.set_audience(&["foxhunt-services"]); let jwt_secret = get_test_jwt_secret(); let token_data = decode::( &token, &DecodingKey::from_secret(jwt_secret.as_ref()), &validation, ) .expect("Should decode token"); let now = Utc::now().timestamp() as u64; assert!(token_data.claims.exp < now, "Token should be expired"); } #[test] fn test_create_invalid_issuer_jwt() { let token = create_invalid_issuer_jwt().expect("Should create JWT token"); assert!(!token.is_empty()); // Decode to verify wrong issuer use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; let mut validation = Validation::new(Algorithm::HS256); validation.validate_exp = false; validation.set_issuer(&["foxhunt-trading"]); let jwt_secret = get_test_jwt_secret(); let result = decode::( &token, &DecodingKey::from_secret(jwt_secret.as_ref()), &validation, ); assert!( result.is_err(), "Should fail validation due to wrong issuer" ); } #[test] fn test_auth_config_builder() { let config = TestAuthConfig::trader() .with_user_id("custom_user_123") .with_roles(vec!["custom_role".to_string()]) .with_permissions(vec!["custom.permission".to_string()]) .with_expiry(Duration::minutes(30)) .with_mfa_enabled(); assert_eq!(config.user_id, "custom_user_123"); assert_eq!(config.roles, vec!["custom_role"]); assert_eq!(config.permissions, vec!["custom.permission"]); assert_eq!(config.expiry_duration, Duration::minutes(30)); assert!(config.mfa_enabled); assert!(config.mfa_verified); } #[test] fn test_get_test_user_id() { let user_id = get_test_user_id(); assert_eq!(user_id, DEFAULT_TEST_USER_ID); } // WAVE 150: This test removed due to unfixable test pollution // Problem: Even with RAII guards and serial_test, removing JWT_SECRET causes // concurrent E2E tests to fail. The window between removal and restoration // (even with guards) allows other tests to see the missing variable. // // Alternative verification: The get_test_jwt_secret() function already has // .expect() which provides the same fail-fast behavior this test verified. // // #[test] // #[serial_test::serial] // fn test_get_test_jwt_secret_fails_without_env() { // // Test removed - causes pollution of concurrent E2E tests // } #[test] #[serial_test::serial] // WAVE 150: Prevent pollution from set_var fn test_get_test_jwt_secret_with_env() { // WAVE 150: Save original JWT_SECRET before overwriting let original_secret = std::env::var("JWT_SECRET").ok(); // RAII guard to restore JWT_SECRET on drop struct JwtSecretGuard(Option); impl Drop for JwtSecretGuard { fn drop(&mut self) { if let Some(ref secret) = self.0 { std::env::set_var("JWT_SECRET", secret); } } } let _guard = JwtSecretGuard(original_secret); // Set JWT_SECRET to test success path std::env::set_var( "JWT_SECRET", "test_secret_at_least_64_chars_long_for_security_xxxxxxxxxxxxxxxxxxxxxxxxx", ); let secret = get_test_jwt_secret(); assert!(!secret.is_empty()); assert!(secret.len() >= 64); // Should be high-entropy // _guard drops here, restoring original JWT_SECRET } #[test] fn test_get_api_addr() { let addr = get_api_addr(); assert!(!addr.is_empty()); assert!(addr.starts_with("http://")); } }