chore: vendor sglang v0.5.10 snapshot
This commit is contained in:
937
third_party/sglang/sgl-model-gateway/tests/security/auth_integration_test.rs
vendored
Normal file
937
third_party/sglang/sgl-model-gateway/tests/security/auth_integration_test.rs
vendored
Normal file
@@ -0,0 +1,937 @@
|
||||
//! Integration tests for control plane authentication.
|
||||
//!
|
||||
//! Tests the full authentication flow including:
|
||||
//! - JWT token validation with mock JWKS server
|
||||
//! - API key authentication
|
||||
//! - Role-based access control
|
||||
//! - Token expiration and replay protection
|
||||
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::LazyLock,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{routing::get, Json, Router};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use rsa::{traits::PublicKeyParts, RsaPrivateKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use smg::auth::{ApiKeyEntry, ControlPlaneAuthConfig, ControlPlaneAuthState, JwtConfig, Role};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const TEST_KEY_ID: &str = "test-key-1";
|
||||
|
||||
/// Test RSA key pair - generates matching public/private key components
|
||||
struct TestKeyPair {
|
||||
private_key_pem: String,
|
||||
n_base64url: String,
|
||||
e_base64url: String,
|
||||
}
|
||||
|
||||
impl TestKeyPair {
|
||||
fn generate() -> Self {
|
||||
// Generate a new 2048-bit RSA key pair for testing
|
||||
use rsa::{pkcs8::EncodePrivateKey, rand_core::OsRng};
|
||||
|
||||
let mut rng = OsRng;
|
||||
let private_key =
|
||||
RsaPrivateKey::new(&mut rng, 2048).expect("Failed to generate RSA key pair");
|
||||
|
||||
// Get the public key components
|
||||
let n_bytes = private_key.n().to_bytes_be();
|
||||
let e_bytes = private_key.e().to_bytes_be();
|
||||
|
||||
// Encode to base64url (no padding) for JWKS
|
||||
let n_base64url = URL_SAFE_NO_PAD.encode(&n_bytes);
|
||||
let e_base64url = URL_SAFE_NO_PAD.encode(&e_bytes);
|
||||
|
||||
// Export private key as PEM
|
||||
let private_key_pem = private_key
|
||||
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
|
||||
.expect("Failed to export private key")
|
||||
.to_string();
|
||||
|
||||
Self {
|
||||
private_key_pem,
|
||||
n_base64url,
|
||||
e_base64url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily initialized test key pair (shared across all tests)
|
||||
static TEST_KEYS: LazyLock<TestKeyPair> = LazyLock::new(TestKeyPair::generate);
|
||||
|
||||
/// Claims structure for test tokens
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TestClaims {
|
||||
sub: String,
|
||||
iss: String,
|
||||
aud: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jti: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
roles: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Create a test JWT token using the generated test key pair
|
||||
fn create_test_token(claims: &TestClaims) -> String {
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::RS256);
|
||||
header.kid = Some(TEST_KEY_ID.to_string());
|
||||
|
||||
let key = EncodingKey::from_rsa_pem(TEST_KEYS.private_key_pem.as_bytes())
|
||||
.expect("Failed to create encoding key");
|
||||
|
||||
encode(&header, claims, &key).expect("Failed to encode token")
|
||||
}
|
||||
|
||||
/// Create claims with default values
|
||||
fn create_claims(sub: &str, roles: Vec<&str>) -> TestClaims {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
TestClaims {
|
||||
sub: sub.to_string(),
|
||||
iss: "https://test-issuer.example.com".to_string(),
|
||||
aud: "test-gateway".to_string(),
|
||||
exp: now + 3600, // 1 hour from now
|
||||
iat: now,
|
||||
jti: Some(uuid::Uuid::new_v4().to_string()),
|
||||
name: Some("Test User".to_string()),
|
||||
roles: Some(roles.into_iter().map(String::from).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a mock JWKS server using the generated test key pair
|
||||
async fn start_mock_jwks_server() -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
// Use the generated key pair components
|
||||
let jwks_response = json!({
|
||||
"keys": [{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"kid": TEST_KEY_ID,
|
||||
"alg": "RS256",
|
||||
"n": TEST_KEYS.n_base64url,
|
||||
"e": TEST_KEYS.e_base64url
|
||||
}]
|
||||
});
|
||||
|
||||
let app = Router::new().route(
|
||||
"/.well-known/jwks.json",
|
||||
get(move || {
|
||||
let jwks = jwks_response.clone();
|
||||
async move { Json(jwks) }
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Give server time to start
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT Authentication Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_valid_token_with_admin_role() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("admin", Role::Admin);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create a valid token with admin role
|
||||
let claims = create_claims("admin@example.com", vec!["admin"]);
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
// Validate the token
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Token validation failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let validated = result.unwrap();
|
||||
assert_eq!(validated.subject, "admin@example.com");
|
||||
assert_eq!(validated.role, Role::Admin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_valid_token_with_user_role() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("admin", Role::Admin);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create a valid token with user role (not admin)
|
||||
let claims = create_claims("user@example.com", vec!["user", "viewer"]);
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let validated = result.unwrap();
|
||||
assert_eq!(validated.subject, "user@example.com");
|
||||
assert_eq!(validated.role, Role::User); // Not admin
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_expired_token() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
));
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create an expired token
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let claims = TestClaims {
|
||||
sub: "user@example.com".to_string(),
|
||||
iss: "https://test-issuer.example.com".to_string(),
|
||||
aud: "test-gateway".to_string(),
|
||||
exp: now - 3600, // Expired 1 hour ago
|
||||
iat: now - 7200,
|
||||
jti: None,
|
||||
name: None,
|
||||
roles: Some(vec!["admin".to_string()]),
|
||||
};
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(result.is_err(), "Expired token should fail validation");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("exp") || err.to_string().contains("ExpiredSignature"),
|
||||
"Error should mention expiration: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_wrong_audience() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config =
|
||||
JwtConfig::new("https://test-issuer.example.com", "correct-audience").with_jwks_uri(
|
||||
format!("http://127.0.0.1:{}/.well-known/jwks.json", addr.port()),
|
||||
);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create a token with wrong audience
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let claims = TestClaims {
|
||||
sub: "user@example.com".to_string(),
|
||||
iss: "https://test-issuer.example.com".to_string(),
|
||||
aud: "wrong-audience".to_string(), // Wrong audience
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
jti: None,
|
||||
name: None,
|
||||
roles: Some(vec!["admin".to_string()]),
|
||||
};
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(result.is_err(), "Wrong audience should fail validation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_wrong_issuer() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config =
|
||||
JwtConfig::new("https://correct-issuer.example.com", "test-gateway").with_jwks_uri(
|
||||
format!("http://127.0.0.1:{}/.well-known/jwks.json", addr.port()),
|
||||
);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create a token with wrong issuer
|
||||
let claims = create_claims("user@example.com", vec!["admin"]);
|
||||
// claims has issuer "https://test-issuer.example.com" which doesn't match
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(result.is_err(), "Wrong issuer should fail validation");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Key Authentication Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_valid_admin() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new(
|
||||
"admin-key-1",
|
||||
"Admin Service Account",
|
||||
"sk-test-admin-key-12345",
|
||||
Role::Admin,
|
||||
)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
// Verify we can find the API key
|
||||
let found = config.find_api_key("sk-test-admin-key-12345");
|
||||
assert!(found.is_some(), "API key should be found");
|
||||
|
||||
let entry = found.unwrap();
|
||||
assert_eq!(entry.id, "admin-key-1");
|
||||
assert_eq!(entry.role, Role::Admin);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_valid_user() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new(
|
||||
"user-key-1",
|
||||
"User Service Account",
|
||||
"sk-test-user-key-12345",
|
||||
Role::User,
|
||||
)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let found = config.find_api_key("sk-test-user-key-12345");
|
||||
assert!(found.is_some());
|
||||
|
||||
let entry = found.unwrap();
|
||||
assert_eq!(entry.role, Role::User);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_invalid() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new(
|
||||
"admin-key-1",
|
||||
"Admin Service Account",
|
||||
"sk-correct-key",
|
||||
Role::Admin,
|
||||
)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
// Try with wrong key
|
||||
let found = config.find_api_key("sk-wrong-key");
|
||||
assert!(found.is_none(), "Wrong API key should not be found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_timing_attack_resistance() {
|
||||
// Verify that key comparison uses constant-time comparison
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new(
|
||||
"key-1",
|
||||
"Test Key",
|
||||
"sk-abcdefghijklmnop",
|
||||
Role::Admin,
|
||||
)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
// These should all take roughly the same time due to constant-time comparison
|
||||
// (We can't easily test timing in unit tests, but we verify the function works)
|
||||
assert!(config.find_api_key("sk-abcdefghijklmnop").is_some());
|
||||
assert!(config.find_api_key("sk-abcdefghijklmnox").is_none()); // One char different
|
||||
assert!(config.find_api_key("sk-xxxxxxxxxxxxxxxx").is_none()); // All different
|
||||
assert!(config.find_api_key("sk-a").is_none()); // Much shorter
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Combined Auth Tests (JWT + API Key fallback)
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_combined_auth_jwt_and_api_keys() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("admin", Role::Admin);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![ApiKeyEntry::new(
|
||||
"backup-key",
|
||||
"Backup Admin Key",
|
||||
"sk-backup-admin-key",
|
||||
Role::Admin,
|
||||
)],
|
||||
audit_enabled: true,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config.clone())
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Both JWT and API key should work
|
||||
assert!(state.jwt_validator.is_some());
|
||||
assert!(config.find_api_key("sk-backup-admin-key").is_some());
|
||||
|
||||
// Verify JWT works
|
||||
let claims = create_claims("jwt-user@example.com", vec!["admin"]);
|
||||
let token = create_test_token(&claims);
|
||||
let result = state.jwt_validator.as_ref().unwrap().validate(&token).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role-Based Access Control Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_role_admin_has_access() {
|
||||
assert!(Role::Admin.is_admin());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_role_user_no_admin_access() {
|
||||
assert!(!Role::User.is_admin());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_role_parsing() {
|
||||
assert_eq!("admin".parse::<Role>().unwrap(), Role::Admin);
|
||||
assert_eq!("Admin".parse::<Role>().unwrap(), Role::Admin);
|
||||
assert_eq!("ADMIN".parse::<Role>().unwrap(), Role::Admin);
|
||||
assert_eq!("user".parse::<Role>().unwrap(), Role::User);
|
||||
assert_eq!("User".parse::<Role>().unwrap(), Role::User);
|
||||
assert_eq!("USER".parse::<Role>().unwrap(), Role::User);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// try_init Helper Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_init_with_no_config() {
|
||||
let result = ControlPlaneAuthState::try_init(None).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_init_with_disabled_config() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![], // No auth configured = disabled
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let result = ControlPlaneAuthState::try_init(Some(&config)).await;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_try_init_with_api_keys_only() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new("key-1", "Test", "sk-test", Role::Admin)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let result = ControlPlaneAuthState::try_init(Some(&config)).await;
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audit Logging Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_logging_enabled() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new("key-1", "Test", "sk-test", Role::Admin)],
|
||||
audit_enabled: true,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
assert!(state.audit_logger.is_enabled());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_logging_disabled() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new("key-1", "Test", "sk-test", Role::Admin)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
assert!(!state.audit_logger.is_enabled());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JTI Replay Protection Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_jti_replay_protection() {
|
||||
use smg::auth::JwtValidator;
|
||||
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("admin", Role::Admin);
|
||||
|
||||
// Create validator with JTI replay protection enabled
|
||||
let validator = JwtValidator::from_config_with_options(jwt_config, true)
|
||||
.await
|
||||
.expect("Failed to create JWT validator");
|
||||
|
||||
// Create a token with a specific JTI
|
||||
let claims = create_claims("user@example.com", vec!["admin"]);
|
||||
let token = create_test_token(&claims);
|
||||
|
||||
// First validation should succeed
|
||||
let result1 = validator.validate(&token).await;
|
||||
assert!(result1.is_ok(), "First validation should succeed");
|
||||
|
||||
// Second validation with same token should fail (replay)
|
||||
let result2 = validator.validate(&token).await;
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"Second validation should fail (replay detected)"
|
||||
);
|
||||
let err = result2.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("replay") || err.to_string().contains("already been used"),
|
||||
"Error should mention replay: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_different_tokens_no_replay() {
|
||||
use smg::auth::JwtValidator;
|
||||
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("admin", Role::Admin);
|
||||
|
||||
// Create validator with JTI replay protection enabled
|
||||
let validator = JwtValidator::from_config_with_options(jwt_config, true)
|
||||
.await
|
||||
.expect("Failed to create JWT validator");
|
||||
|
||||
// Create two different tokens (different JTIs)
|
||||
let claims1 = create_claims("user1@example.com", vec!["admin"]);
|
||||
let claims2 = create_claims("user2@example.com", vec!["admin"]);
|
||||
let token1 = create_test_token(&claims1);
|
||||
let token2 = create_test_token(&claims2);
|
||||
|
||||
// Both should succeed since they have different JTIs
|
||||
let result1 = validator.validate(&token1).await;
|
||||
assert!(result1.is_ok(), "First token validation should succeed");
|
||||
|
||||
let result2 = validator.validate(&token2).await;
|
||||
assert!(
|
||||
result2.is_ok(),
|
||||
"Second token validation should succeed (different JTI)"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Malformed Token Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_malformed_token() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
));
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
|
||||
// Test various malformed tokens
|
||||
let malformed_tokens = [
|
||||
"not-a-jwt",
|
||||
"only.two.parts.is.not.enough",
|
||||
"",
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", // Only header
|
||||
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.", // Header with dot
|
||||
".....",
|
||||
];
|
||||
|
||||
for token in malformed_tokens {
|
||||
let result = jwt_validator.validate(token).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Malformed token '{}' should fail validation",
|
||||
token
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_missing_kid_in_header() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
));
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create a token WITHOUT kid in header
|
||||
let claims = create_claims("user@example.com", vec!["admin"]);
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::RS256);
|
||||
header.kid = None; // Explicitly no kid
|
||||
|
||||
let key = EncodingKey::from_rsa_pem(TEST_KEYS.private_key_pem.as_bytes())
|
||||
.expect("Failed to create encoding key");
|
||||
let token_without_kid = encode(&header, &claims, &key).expect("Failed to encode token");
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token_without_kid).await;
|
||||
|
||||
assert!(result.is_err(), "Token without kid should fail validation");
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("kid") || err.to_string().contains("Missing"),
|
||||
"Error should mention missing kid: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Cases and Security Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_role_extraction_from_groups_claim() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
// JWT validator checks alternate claims ("groups", "roles", etc.) as fallback
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
))
|
||||
.with_role_mapping("administrators", Role::Admin);
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create token with 'groups' claim instead of 'roles'
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GroupsClaims {
|
||||
sub: String,
|
||||
iss: String,
|
||||
aud: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
jti: String,
|
||||
groups: Vec<String>, // Use 'groups' instead of 'roles'
|
||||
}
|
||||
|
||||
let claims = GroupsClaims {
|
||||
sub: "group-user@example.com".to_string(),
|
||||
iss: "https://test-issuer.example.com".to_string(),
|
||||
aud: "test-gateway".to_string(),
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
groups: vec!["administrators".to_string(), "developers".to_string()],
|
||||
};
|
||||
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::RS256);
|
||||
header.kid = Some(TEST_KEY_ID.to_string());
|
||||
let key = EncodingKey::from_rsa_pem(TEST_KEYS.private_key_pem.as_bytes())
|
||||
.expect("Failed to create encoding key");
|
||||
let token = encode(&header, &claims, &key).expect("Failed to encode token");
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Token with groups claim should validate: {:?}",
|
||||
result.err()
|
||||
);
|
||||
let validated = result.unwrap();
|
||||
assert_eq!(
|
||||
validated.role,
|
||||
Role::Admin,
|
||||
"Should map 'administrators' to Admin role"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jwt_no_role_defaults_to_user() {
|
||||
let (addr, _server) = start_mock_jwks_server().await;
|
||||
|
||||
let jwt_config = JwtConfig::new("https://test-issuer.example.com", "test-gateway")
|
||||
.with_jwks_uri(format!(
|
||||
"http://127.0.0.1:{}/.well-known/jwks.json",
|
||||
addr.port()
|
||||
));
|
||||
// No role mapping configured
|
||||
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: Some(jwt_config),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
let state = ControlPlaneAuthState::from_config(config)
|
||||
.await
|
||||
.expect("Failed to create auth state");
|
||||
|
||||
// Create token without any role claims
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MinimalClaims {
|
||||
sub: String,
|
||||
iss: String,
|
||||
aud: String,
|
||||
exp: u64,
|
||||
iat: u64,
|
||||
jti: String,
|
||||
// No roles, groups, or role claims
|
||||
}
|
||||
|
||||
let claims = MinimalClaims {
|
||||
sub: "minimal-user@example.com".to_string(),
|
||||
iss: "https://test-issuer.example.com".to_string(),
|
||||
aud: "test-gateway".to_string(),
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
jti: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
|
||||
let mut header = Header::new(jsonwebtoken::Algorithm::RS256);
|
||||
header.kid = Some(TEST_KEY_ID.to_string());
|
||||
let key = EncodingKey::from_rsa_pem(TEST_KEYS.private_key_pem.as_bytes())
|
||||
.expect("Failed to create encoding key");
|
||||
let token = encode(&header, &claims, &key).expect("Failed to encode token");
|
||||
|
||||
let jwt_validator = state.jwt_validator.as_ref().expect("JWT validator not set");
|
||||
let result = jwt_validator.validate(&token).await;
|
||||
|
||||
assert!(result.is_ok(), "Token without role should still validate");
|
||||
let validated = result.unwrap();
|
||||
assert_eq!(
|
||||
validated.role,
|
||||
Role::User,
|
||||
"Should default to User role when no role found"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_api_keys() {
|
||||
let config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![
|
||||
ApiKeyEntry::new("admin-1", "Admin Key 1", "sk-admin-key-1", Role::Admin),
|
||||
ApiKeyEntry::new("admin-2", "Admin Key 2", "sk-admin-key-2", Role::Admin),
|
||||
ApiKeyEntry::new("user-1", "User Key 1", "sk-user-key-1", Role::User),
|
||||
],
|
||||
audit_enabled: false,
|
||||
};
|
||||
|
||||
// All keys should be findable
|
||||
assert!(config.find_api_key("sk-admin-key-1").is_some());
|
||||
assert!(config.find_api_key("sk-admin-key-2").is_some());
|
||||
assert!(config.find_api_key("sk-user-key-1").is_some());
|
||||
|
||||
// Verify correct roles
|
||||
assert_eq!(
|
||||
config.find_api_key("sk-admin-key-1").unwrap().role,
|
||||
Role::Admin
|
||||
);
|
||||
assert_eq!(
|
||||
config.find_api_key("sk-user-key-1").unwrap().role,
|
||||
Role::User
|
||||
);
|
||||
|
||||
// Non-existent key should not be found
|
||||
assert!(config.find_api_key("sk-nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_is_enabled_checks() {
|
||||
// Empty config is not enabled
|
||||
let empty_config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
assert!(!empty_config.is_enabled());
|
||||
|
||||
// Config with only API keys is enabled
|
||||
let api_key_config = ControlPlaneAuthConfig {
|
||||
jwt: None,
|
||||
api_keys: vec![ApiKeyEntry::new("key-1", "Test", "sk-test", Role::Admin)],
|
||||
audit_enabled: false,
|
||||
};
|
||||
assert!(api_key_config.is_enabled());
|
||||
|
||||
// Config with only JWT is enabled
|
||||
let jwt_config = ControlPlaneAuthConfig {
|
||||
jwt: Some(JwtConfig::new("https://issuer.example.com", "audience")),
|
||||
api_keys: vec![],
|
||||
audit_enabled: false,
|
||||
};
|
||||
assert!(jwt_config.is_enabled());
|
||||
|
||||
// Config with both is enabled
|
||||
let full_config = ControlPlaneAuthConfig {
|
||||
jwt: Some(JwtConfig::new("https://issuer.example.com", "audience")),
|
||||
api_keys: vec![ApiKeyEntry::new("key-1", "Test", "sk-test", Role::Admin)],
|
||||
audit_enabled: true,
|
||||
};
|
||||
assert!(full_config.is_enabled());
|
||||
}
|
||||
243
third_party/sglang/sgl-model-gateway/tests/security/auth_test.rs
vendored
Normal file
243
third_party/sglang/sgl-model-gateway/tests/security/auth_test.rs
vendored
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Authentication and authorization integration tests
|
||||
//!
|
||||
//! Tests for API key enforcement and access control.
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
http::{header::CONTENT_TYPE, StatusCode},
|
||||
};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::{AppTestContext, TestRouterConfig, TestWorkerConfig};
|
||||
|
||||
const AUTH_HEADER: &str = "Authorization";
|
||||
|
||||
#[cfg(test)]
|
||||
mod auth_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test request without API key when auth is not required
|
||||
#[tokio::test]
|
||||
async fn test_no_auth_required() {
|
||||
let config = TestRouterConfig::round_robin(4300);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20300)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
|
||||
// Request without auth header should succeed when no auth required
|
||||
let payload = json!({
|
||||
"text": "Test without auth",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/generate")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"Request without auth should succeed when no auth required"
|
||||
);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
|
||||
/// Test request with valid API key format
|
||||
#[tokio::test]
|
||||
async fn test_with_api_key_header() {
|
||||
let config = TestRouterConfig::round_robin(4301);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20301)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
|
||||
// Request with Bearer token header
|
||||
let payload = json!({
|
||||
"text": "Test with auth header",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/generate")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(AUTH_HEADER, "Bearer test-api-key-12345")
|
||||
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
// Without auth enforcement, request should succeed
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"Request with auth header should succeed"
|
||||
);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
|
||||
/// Test health endpoint doesn't require authentication
|
||||
#[tokio::test]
|
||||
async fn test_health_endpoint_no_auth() {
|
||||
let config = TestRouterConfig::round_robin(4302);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20302)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
|
||||
// Health endpoint should be accessible without auth
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/health")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"Health endpoint should not require auth"
|
||||
);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
|
||||
/// Test OpenAI-compatible API key header (X-API-Key)
|
||||
#[tokio::test]
|
||||
async fn test_openai_api_key_header() {
|
||||
let config = TestRouterConfig::round_robin(4303);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20303)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
|
||||
// Request with X-API-Key header (OpenAI style)
|
||||
let payload = json!({
|
||||
"text": "Test with X-API-Key",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/generate")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("X-API-Key", "sk-test-key-12345")
|
||||
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"Request with X-API-Key should succeed"
|
||||
);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
|
||||
/// Test multiple concurrent authenticated requests
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_authenticated_requests() {
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
let config = TestRouterConfig::round_robin(4304);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20304)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for i in 0..20 {
|
||||
let app_clone = app.clone();
|
||||
let success_clone = Arc::clone(&success_count);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"text": format!("Concurrent auth test {}", i),
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/generate")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(AUTH_HEADER, format!("Bearer test-key-{}", i))
|
||||
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app_clone.oneshot(req).await.unwrap();
|
||||
if resp.status() == StatusCode::OK {
|
||||
success_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
success_count.load(Ordering::SeqCst),
|
||||
20,
|
||||
"All concurrent authenticated requests should succeed"
|
||||
);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mtls_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test that TLS configuration options exist
|
||||
/// Note: Actual mTLS testing would require certificate setup
|
||||
#[tokio::test]
|
||||
async fn test_tls_config_available() {
|
||||
// This test verifies the config builder accepts TLS-related options
|
||||
// Actual mTLS testing requires certificate infrastructure
|
||||
let config = TestRouterConfig::round_robin(4305);
|
||||
|
||||
let ctx =
|
||||
AppTestContext::new_with_config(config, vec![TestWorkerConfig::healthy(20305)]).await;
|
||||
|
||||
let app = ctx.create_app().await;
|
||||
|
||||
// Basic request should work (no TLS in test mode)
|
||||
let payload = json!({
|
||||
"text": "TLS config test",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/generate")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_string(&payload).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
ctx.shutdown().await;
|
||||
}
|
||||
}
|
||||
5
third_party/sglang/sgl-model-gateway/tests/security/mod.rs
vendored
Normal file
5
third_party/sglang/sgl-model-gateway/tests/security/mod.rs
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Security integration tests
|
||||
|
||||
pub mod auth_integration_test;
|
||||
pub mod auth_test;
|
||||
pub mod mtls_test;
|
||||
506
third_party/sglang/sgl-model-gateway/tests/security/mtls_test.rs
vendored
Normal file
506
third_party/sglang/sgl-model-gateway/tests/security/mtls_test.rs
vendored
Normal file
@@ -0,0 +1,506 @@
|
||||
//! mTLS (Mutual TLS) integration tests
|
||||
//!
|
||||
//! Tests for TLS and mTLS communication between router and workers.
|
||||
//! Covers:
|
||||
//! - Successful mTLS communication with client certificates
|
||||
//! - TLS failure without client certificate when required
|
||||
//! - TLS-only mode (server authentication only)
|
||||
//! - TLS failure without CA certificate
|
||||
|
||||
use std::{io::BufReader, time::Duration};
|
||||
|
||||
use rustls::{ClientConfig, RootCertStore};
|
||||
use rustls_pemfile::{certs, pkcs8_private_keys};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::{
|
||||
test_certs::TestCertificates,
|
||||
tls_mock_worker::{TlsMockWorker, TlsMockWorkerConfig},
|
||||
};
|
||||
|
||||
// Ensure crypto provider is installed
|
||||
fn ensure_crypto_provider() {
|
||||
use std::sync::Once;
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper to create a TLS-enabled reqwest client with client certificates
|
||||
fn create_mtls_client(
|
||||
test_certs: &TestCertificates,
|
||||
) -> Result<reqwest::Client, Box<dyn std::error::Error>> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
// Read CA certificate
|
||||
let ca_file = std::fs::File::open(&test_certs.ca_cert_path)?;
|
||||
let mut ca_reader = BufReader::new(ca_file);
|
||||
let ca_certs: Vec<_> = certs(&mut ca_reader).filter_map(|r| r.ok()).collect();
|
||||
|
||||
let mut root_store = RootCertStore::empty();
|
||||
for cert in ca_certs {
|
||||
root_store.add(cert)?;
|
||||
}
|
||||
|
||||
// Read client certificate
|
||||
let cert_file = std::fs::File::open(&test_certs.client_cert_path)?;
|
||||
let mut cert_reader = BufReader::new(cert_file);
|
||||
let client_certs: Vec<_> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
|
||||
|
||||
// Read client key
|
||||
let key_file = std::fs::File::open(&test_certs.client_key_path)?;
|
||||
let mut key_reader = BufReader::new(key_file);
|
||||
let client_key = pkcs8_private_keys(&mut key_reader)
|
||||
.next()
|
||||
.ok_or("No private key found")??;
|
||||
|
||||
// Build client config with client certificate
|
||||
let client_config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_client_auth_cert(client_certs, client_key.into())
|
||||
.map_err(|e| format!("Failed to build client config: {}", e))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.use_preconfigured_tls(client_config)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Helper to create a TLS client without client certificates (for server-auth-only)
|
||||
fn create_tls_client(
|
||||
test_certs: &TestCertificates,
|
||||
) -> Result<reqwest::Client, Box<dyn std::error::Error>> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
// Read CA certificate
|
||||
let ca_file = std::fs::File::open(&test_certs.ca_cert_path)?;
|
||||
let mut ca_reader = BufReader::new(ca_file);
|
||||
let ca_certs: Vec<_> = certs(&mut ca_reader).filter_map(|r| r.ok()).collect();
|
||||
|
||||
let mut root_store = RootCertStore::empty();
|
||||
for cert in ca_certs {
|
||||
root_store.add(cert)?;
|
||||
}
|
||||
|
||||
// Build client config without client certificate
|
||||
let client_config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.use_preconfigured_tls(client_config)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Helper to create a client without CA certificate (for failure test)
|
||||
fn create_client_without_ca() -> Result<reqwest::Client, Box<dyn std::error::Error>> {
|
||||
ensure_crypto_provider();
|
||||
|
||||
// Build client with empty root store (will fail to verify server)
|
||||
let root_store = RootCertStore::empty();
|
||||
let client_config = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.use_preconfigured_tls(client_config)
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mtls_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test successful mTLS communication between client and TLS worker
|
||||
///
|
||||
/// This test verifies that:
|
||||
/// 1. TLS mock worker starts with mTLS configuration
|
||||
/// 2. Client with proper certificates can connect and communicate
|
||||
/// 3. Requests succeed with proper authentication
|
||||
#[tokio::test]
|
||||
async fn test_mtls_successful_communication() {
|
||||
// Generate test certificates
|
||||
let certs = TestCertificates::generate().expect("Failed to generate test certificates");
|
||||
|
||||
// Start mTLS-enabled mock worker
|
||||
let mut worker = TlsMockWorker::new(TlsMockWorkerConfig {
|
||||
port: 0, // Auto-assign port
|
||||
require_client_cert: true,
|
||||
response_delay_ms: 0,
|
||||
fail_rate: 0.0,
|
||||
});
|
||||
|
||||
let worker_url = worker
|
||||
.start(
|
||||
&certs.server_cert_path,
|
||||
&certs.server_key_path,
|
||||
Some(&certs.ca_cert_path),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start mTLS worker");
|
||||
|
||||
// Create client with full mTLS credentials
|
||||
let client = create_mtls_client(&certs).expect("Failed to create mTLS client");
|
||||
|
||||
// Test health endpoint
|
||||
let health_resp = client
|
||||
.get(format!("{}/health", worker_url))
|
||||
.send()
|
||||
.await
|
||||
.expect("Health request failed");
|
||||
|
||||
assert!(
|
||||
health_resp.status().is_success(),
|
||||
"Health check should succeed with mTLS: {}",
|
||||
health_resp.status()
|
||||
);
|
||||
|
||||
let health_json: serde_json::Value = health_resp.json().await.unwrap();
|
||||
assert_eq!(health_json["status"], "healthy");
|
||||
assert_eq!(health_json["tls_enabled"], true);
|
||||
|
||||
// Test generate endpoint
|
||||
let payload = json!({
|
||||
"text": "Test mTLS request",
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let gen_resp = client
|
||||
.post(format!("{}/generate", worker_url))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Generate request failed");
|
||||
|
||||
assert!(
|
||||
gen_resp.status().is_success(),
|
||||
"Generate should succeed with mTLS: {}",
|
||||
gen_resp.status()
|
||||
);
|
||||
|
||||
let gen_json: serde_json::Value = gen_resp.json().await.unwrap();
|
||||
assert!(gen_json["text"].as_str().is_some());
|
||||
assert_eq!(gen_json["meta_info"]["tls_verified"], true);
|
||||
|
||||
// Cleanup
|
||||
worker.stop().await;
|
||||
}
|
||||
|
||||
/// Test that mTLS worker rejects connections without client certificate
|
||||
///
|
||||
/// This test verifies that:
|
||||
/// 1. mTLS worker requires client certificate
|
||||
/// 2. Connection without client cert fails
|
||||
#[tokio::test]
|
||||
async fn test_mtls_failure_without_client_cert() {
|
||||
// Generate test certificates
|
||||
let certs = TestCertificates::generate().expect("Failed to generate test certificates");
|
||||
|
||||
// Start mTLS-enabled mock worker (requires client cert)
|
||||
let mut worker = TlsMockWorker::new(TlsMockWorkerConfig {
|
||||
port: 0,
|
||||
require_client_cert: true,
|
||||
response_delay_ms: 0,
|
||||
fail_rate: 0.0,
|
||||
});
|
||||
|
||||
let worker_url = worker
|
||||
.start(
|
||||
&certs.server_cert_path,
|
||||
&certs.server_key_path,
|
||||
Some(&certs.ca_cert_path),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start mTLS worker");
|
||||
|
||||
// Create client WITHOUT client certificate (only has CA for server verification)
|
||||
let client = create_tls_client(&certs).expect("Failed to create TLS-only client");
|
||||
|
||||
// Attempt to connect - should fail because no client cert provided
|
||||
let result = client.get(format!("{}/health", worker_url)).send().await;
|
||||
|
||||
// Connection should fail or be rejected
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Connection should fail without client certificate"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
worker.stop().await;
|
||||
}
|
||||
|
||||
/// Test TLS-only mode (server authentication only, no client cert required)
|
||||
///
|
||||
/// This test verifies that:
|
||||
/// 1. TLS worker can operate without requiring client certificates
|
||||
/// 2. Client can connect with just CA certificate for server verification
|
||||
#[tokio::test]
|
||||
async fn test_tls_server_auth_only() {
|
||||
// Generate test certificates
|
||||
let certs = TestCertificates::generate().expect("Failed to generate test certificates");
|
||||
|
||||
// Start TLS-only mock worker (does NOT require client cert)
|
||||
let mut worker = TlsMockWorker::new(TlsMockWorkerConfig {
|
||||
port: 0,
|
||||
require_client_cert: false, // TLS-only mode
|
||||
response_delay_ms: 0,
|
||||
fail_rate: 0.0,
|
||||
});
|
||||
|
||||
let worker_url = worker
|
||||
.start(
|
||||
&certs.server_cert_path,
|
||||
&certs.server_key_path,
|
||||
None, // No CA needed for client verification
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start TLS worker");
|
||||
|
||||
// Create client with just CA cert for server verification (no client cert)
|
||||
let client = create_tls_client(&certs).expect("Failed to create TLS client");
|
||||
|
||||
// Test health endpoint - should succeed without client cert
|
||||
let health_resp = client
|
||||
.get(format!("{}/health", worker_url))
|
||||
.send()
|
||||
.await
|
||||
.expect("Health request failed");
|
||||
|
||||
assert!(
|
||||
health_resp.status().is_success(),
|
||||
"Health check should succeed in TLS-only mode: {}",
|
||||
health_resp.status()
|
||||
);
|
||||
|
||||
let health_json: serde_json::Value = health_resp.json().await.unwrap();
|
||||
assert_eq!(health_json["status"], "healthy");
|
||||
|
||||
// Test chat completions endpoint
|
||||
let payload = json!({
|
||||
"model": "mock-tls-model",
|
||||
"messages": [{"role": "user", "content": "Hello TLS"}]
|
||||
});
|
||||
|
||||
let chat_resp = client
|
||||
.post(format!("{}/v1/chat/completions", worker_url))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Chat request failed");
|
||||
|
||||
assert!(
|
||||
chat_resp.status().is_success(),
|
||||
"Chat should succeed in TLS-only mode: {}",
|
||||
chat_resp.status()
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
worker.stop().await;
|
||||
}
|
||||
|
||||
/// Test TLS failure when client doesn't have CA certificate
|
||||
///
|
||||
/// This test verifies that:
|
||||
/// 1. Client cannot verify server without proper CA certificate
|
||||
/// 2. Connection fails due to certificate verification
|
||||
#[tokio::test]
|
||||
async fn test_tls_failure_without_ca_cert() {
|
||||
// Generate test certificates
|
||||
let certs = TestCertificates::generate().expect("Failed to generate test certificates");
|
||||
|
||||
// Start TLS mock worker
|
||||
let mut worker = TlsMockWorker::new(TlsMockWorkerConfig {
|
||||
port: 0,
|
||||
require_client_cert: false,
|
||||
response_delay_ms: 0,
|
||||
fail_rate: 0.0,
|
||||
});
|
||||
|
||||
let worker_url = worker
|
||||
.start(&certs.server_cert_path, &certs.server_key_path, None)
|
||||
.await
|
||||
.expect("Failed to start TLS worker");
|
||||
|
||||
// Create client WITHOUT CA certificate
|
||||
let client = create_client_without_ca().expect("Failed to create client without CA");
|
||||
|
||||
// Attempt to connect - should fail because cannot verify server cert
|
||||
let result = client.get(format!("{}/health", worker_url)).send().await;
|
||||
|
||||
// Connection should fail due to certificate verification
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Connection should fail without CA certificate for server verification"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
worker.stop().await;
|
||||
}
|
||||
|
||||
/// Test multiple concurrent mTLS requests
|
||||
///
|
||||
/// This test verifies that mTLS connections work correctly under concurrent load
|
||||
#[tokio::test]
|
||||
async fn test_mtls_concurrent_requests() {
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
// Generate test certificates
|
||||
let certs = TestCertificates::generate().expect("Failed to generate test certificates");
|
||||
|
||||
// Start mTLS-enabled mock worker
|
||||
let mut worker = TlsMockWorker::new(TlsMockWorkerConfig {
|
||||
port: 0,
|
||||
require_client_cert: true,
|
||||
response_delay_ms: 10, // Small delay to simulate work
|
||||
fail_rate: 0.0,
|
||||
});
|
||||
|
||||
let worker_url = worker
|
||||
.start(
|
||||
&certs.server_cert_path,
|
||||
&certs.server_key_path,
|
||||
Some(&certs.ca_cert_path),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start mTLS worker");
|
||||
|
||||
// Create mTLS client
|
||||
let client = Arc::new(create_mtls_client(&certs).expect("Failed to create mTLS client"));
|
||||
let success_count = Arc::new(AtomicUsize::new(0));
|
||||
let worker_url = Arc::new(worker_url);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// Spawn concurrent requests
|
||||
for i in 0..10 {
|
||||
let client_clone = Arc::clone(&client);
|
||||
let success_clone = Arc::clone(&success_count);
|
||||
let url_clone = Arc::clone(&worker_url);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let payload = json!({
|
||||
"text": format!("Concurrent mTLS request {}", i),
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let resp = client_clone
|
||||
.post(format!("{}/generate", url_clone))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
if let Ok(response) = resp {
|
||||
if response.status().is_success() {
|
||||
success_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for all requests to complete
|
||||
for handle in handles {
|
||||
handle.await.unwrap();
|
||||
}
|
||||
|
||||
// All requests should succeed
|
||||
assert_eq!(
|
||||
success_count.load(Ordering::SeqCst),
|
||||
10,
|
||||
"All concurrent mTLS requests should succeed"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
worker.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod certificate_tests {
|
||||
use super::*;
|
||||
|
||||
/// Test that certificate generation works correctly
|
||||
#[test]
|
||||
fn test_certificate_generation() {
|
||||
let certs = TestCertificates::generate().expect("Failed to generate certificates");
|
||||
|
||||
// Verify all files exist and are not empty
|
||||
assert!(certs.ca_cert_path.exists());
|
||||
assert!(certs.ca_key_path.exists());
|
||||
assert!(certs.server_cert_path.exists());
|
||||
assert!(certs.server_key_path.exists());
|
||||
assert!(certs.client_cert_path.exists());
|
||||
assert!(certs.client_key_path.exists());
|
||||
|
||||
// Verify PEM format
|
||||
let ca_cert_content = std::fs::read_to_string(&certs.ca_cert_path).unwrap();
|
||||
assert!(ca_cert_content.contains("-----BEGIN CERTIFICATE-----"));
|
||||
assert!(ca_cert_content.contains("-----END CERTIFICATE-----"));
|
||||
|
||||
let server_key_content = std::fs::read_to_string(&certs.server_key_path).unwrap();
|
||||
assert!(server_key_content.contains("-----BEGIN PRIVATE KEY-----"));
|
||||
assert!(server_key_content.contains("-----END PRIVATE KEY-----"));
|
||||
}
|
||||
|
||||
/// Test that generated certificates can be parsed by rustls
|
||||
#[test]
|
||||
fn test_certificate_parsing() {
|
||||
ensure_crypto_provider();
|
||||
|
||||
let test_certs = TestCertificates::generate().expect("Failed to generate certificates");
|
||||
|
||||
// Verify CA certificate can be parsed by rustls
|
||||
let ca_file = std::fs::File::open(&test_certs.ca_cert_path).unwrap();
|
||||
let mut ca_reader = BufReader::new(ca_file);
|
||||
let ca_certs: Vec<_> = certs(&mut ca_reader).filter_map(|r| r.ok()).collect();
|
||||
assert!(!ca_certs.is_empty(), "CA certificate should be parseable");
|
||||
|
||||
// Verify client certificate can be parsed
|
||||
let cert_file = std::fs::File::open(&test_certs.client_cert_path).unwrap();
|
||||
let mut cert_reader = BufReader::new(cert_file);
|
||||
let client_certs: Vec<_> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
|
||||
assert!(
|
||||
!client_certs.is_empty(),
|
||||
"Client certificate should be parseable"
|
||||
);
|
||||
|
||||
// Verify client key can be parsed
|
||||
let key_file = std::fs::File::open(&test_certs.client_key_path).unwrap();
|
||||
let mut key_reader = BufReader::new(key_file);
|
||||
let keys: Vec<_> = pkcs8_private_keys(&mut key_reader)
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
assert!(!keys.is_empty(), "Client key should be parseable");
|
||||
|
||||
// Verify we can build a complete client config
|
||||
let mut root_store = RootCertStore::empty();
|
||||
for cert in ca_certs {
|
||||
root_store
|
||||
.add(cert)
|
||||
.expect("Should be able to add CA cert to store");
|
||||
}
|
||||
|
||||
let key = keys.into_iter().next().unwrap();
|
||||
let config_result = ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_client_auth_cert(client_certs, key.into());
|
||||
|
||||
assert!(
|
||||
config_result.is_ok(),
|
||||
"Should be able to create client config with certs"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user