kindly_guard_server/
auth.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! Authentication and authorization for MCP server
15//! Implements OAuth 2.0 with Resource Indicators (RFC 8707)
16
17use anyhow::Result;
18use base64::{engine::general_purpose, Engine as _};
19use hmac::{Hmac, Mac};
20use rand::Rng;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26use subtle::ConstantTimeEq;
27use tokio::sync::RwLock;
28
29/// OAuth 2.0 token types
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum TokenType {
33    Bearer,
34    Mac,
35}
36
37/// OAuth 2.0 access token
38#[derive(Debug, Clone)]
39pub struct AccessToken {
40    pub token: String,
41    pub token_type: TokenType,
42    pub expires_at: Option<Instant>,
43    pub scopes: Vec<String>,
44    pub resource_indicators: Vec<String>,
45    pub client_id: String,
46}
47
48/// Token validation result
49#[derive(Debug)]
50pub enum TokenValidation {
51    Valid,
52    Expired,
53    Invalid,
54    InsufficientScope,
55    ResourceMismatch,
56}
57
58/// Authentication configuration
59///
60/// # Security Implications
61///
62/// Authentication is critical for preventing unauthorized access:
63/// - **Always enable in production** - Disabling authentication exposes all operations
64/// - **Use strong JWT secrets** - Weak secrets enable token forgery
65/// - **Validate resource indicators** - Prevents token reuse across services
66/// - **Short cache TTLs** - Reduces window for compromised tokens
67///
68/// # Example: Secure Production Configuration
69///
70/// ```toml
71/// [auth]
72/// enabled = true
73/// validation_endpoint = "https://auth.example.com/validate"
74/// trusted_issuers = ["https://auth.example.com"]
75/// cache_ttl_seconds = 300  # 5 minutes
76/// validate_resource_indicators = true
77/// jwt_secret = "base64-encoded-256-bit-secret"
78/// require_signature_verification = true
79///
80/// [auth.required_scopes]
81/// default = ["kindlyguard:access"]
82///
83/// [auth.required_scopes.tools]
84/// "security/scan" = ["security:read"]
85/// "security/neutralize" = ["security:write", "security:admin"]
86/// ```
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct AuthConfig {
89    /// Enable authentication (if false, all requests are allowed)
90    ///
91    /// **Default**: false (for easier testing)
92    /// **Security**: MUST be true in production. When false, anyone can access
93    /// all operations without restriction.
94    /// **Warning**: Running with authentication disabled is a critical security risk
95    pub enabled: bool,
96
97    /// Token validation endpoint (optional, for remote validation)
98    ///
99    /// **Default**: None (local validation only)
100    /// **Security**: Use HTTPS endpoints only. Remote validation adds latency
101    /// but enables centralized token management and revocation.
102    /// **Example**: "https://auth.example.com/oauth2/introspect"
103    pub validation_endpoint: Option<String>,
104
105    /// Trusted issuers
106    ///
107    /// **Default**: empty (no issuers trusted)
108    /// **Security**: Only tokens from these issuers will be accepted.
109    /// Use specific issuer URLs, not wildcards or patterns.
110    /// **Example**: ["https://auth.example.com", "https://login.company.com"]
111    pub trusted_issuers: Vec<String>,
112
113    /// Required scopes for different operations
114    ///
115    /// **Default**: No specific requirements
116    /// **Security**: Define granular scopes to implement least privilege.
117    /// Prevents tokens with limited scopes from accessing sensitive operations.
118    pub required_scopes: ScopeRequirements,
119
120    /// Token cache settings
121    ///
122    /// **Default**: 300 seconds (5 minutes)
123    /// **Security**: Shorter TTLs reduce the window for compromised tokens
124    /// but increase validation overhead. Balance security with performance.
125    /// **Range**: 60-3600 seconds (recommend 300-900 for most cases)
126    pub cache_ttl_seconds: u64,
127
128    /// Enable resource indicators validation
129    ///
130    /// **Default**: true (secure by default)
131    /// **Security**: Validates that tokens are intended for this specific service.
132    /// Prevents token reuse attacks across different services (RFC 8707).
133    /// **Warning**: Disabling allows tokens meant for other services
134    pub validate_resource_indicators: bool,
135
136    /// JWT signing secret (base64 encoded) for HMAC-SHA256 verification
137    ///
138    /// **Default**: None
139    /// **Security**: Use a cryptographically secure 256-bit (32 byte) secret.
140    /// Must be kept confidential and rotated regularly.
141    /// **Generation**: `openssl rand -base64 32`
142    /// **Warning**: Weak secrets enable token forgery attacks
143    pub jwt_secret: Option<String>,
144
145    /// Require JWT signature verification
146    ///
147    /// **Default**: false
148    /// **Security**: When true, all tokens must have valid signatures.
149    /// Essential for preventing token tampering and forgery.
150    /// **Dependencies**: Requires jwt_secret to be configured
151    pub require_signature_verification: bool,
152}
153
154/// Required scopes for different operations
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ScopeRequirements {
157    /// Scopes required for tool execution
158    pub tools: HashMap<String, Vec<String>>,
159
160    /// Scopes required for resource access
161    pub resources: HashMap<String, Vec<String>>,
162
163    /// Default scopes required for any operation
164    pub default: Vec<String>,
165}
166
167impl Default for AuthConfig {
168    fn default() -> Self {
169        Self {
170            enabled: false,
171            validation_endpoint: None,
172            trusted_issuers: vec![],
173            required_scopes: ScopeRequirements::default(),
174            cache_ttl_seconds: 300, // 5 minutes
175            validate_resource_indicators: true,
176            jwt_secret: None,
177            require_signature_verification: false,
178        }
179    }
180}
181
182impl Default for ScopeRequirements {
183    fn default() -> Self {
184        Self {
185            tools: HashMap::new(),
186            resources: HashMap::new(),
187            default: vec!["mcp:read".to_string()],
188        }
189    }
190}
191
192/// Authentication manager
193pub struct AuthManager {
194    config: AuthConfig,
195    token_cache: Arc<RwLock<HashMap<String, CachedToken>>>,
196    server_resource_id: String,
197}
198
199/// Cached token with validation result
200#[allow(missing_docs)] // Internal implementation detail
201struct CachedToken {
202    token: AccessToken,
203    validated_at: Instant,
204    validation_result: TokenValidation,
205}
206
207/// Authorization context for a request
208#[derive(Debug, Clone)]
209pub struct AuthContext {
210    pub authenticated: bool,
211    pub client_id: Option<String>,
212    pub scopes: Vec<String>,
213    pub resource_indicators: Vec<String>,
214}
215
216impl AuthContext {
217    /// Create an unauthenticated context
218    pub const fn unauthenticated() -> Self {
219        Self {
220            authenticated: false,
221            client_id: None,
222            scopes: vec![],
223            resource_indicators: vec![],
224        }
225    }
226
227    /// Check if context has required scope
228    pub fn has_scope(&self, scope: &str) -> bool {
229        self.scopes.iter().any(|s| s == scope || s == "*")
230    }
231
232    /// Check if context has any of the required scopes
233    pub fn has_any_scope(&self, scopes: &[String]) -> bool {
234        scopes.is_empty() || scopes.iter().any(|s| self.has_scope(s))
235    }
236
237    /// Check if context has resource access
238    pub fn has_resource_access(&self, resource: &str) -> bool {
239        self.resource_indicators.is_empty()
240            || self
241                .resource_indicators
242                .iter()
243                .any(|r| r == resource || r == "*")
244    }
245}
246
247impl AuthManager {
248    /// Create a new authentication manager
249    pub fn new(config: AuthConfig, server_resource_id: String) -> Self {
250        Self {
251            config,
252            token_cache: Arc::new(RwLock::new(HashMap::new())),
253            server_resource_id,
254        }
255    }
256
257    /// Perform constant-time comparison of tokens
258    /// This prevents timing attacks by ensuring comparison takes the same time
259    /// regardless of where the first difference occurs
260    pub fn constant_time_compare(a: &str, b: &str) -> bool {
261        let a_bytes = a.as_bytes();
262        let b_bytes = b.as_bytes();
263
264        // First check if lengths are equal (this can leak length info, but that's OK)
265        if a_bytes.len() != b_bytes.len() {
266            return false;
267        }
268
269        // Use subtle crate for constant-time comparison
270        a_bytes.ct_eq(b_bytes).into()
271    }
272
273    /// Generate a cryptographically secure token with high entropy
274    ///
275    /// Generates a token with at least 128 bits of entropy using a secure random
276    /// number generator. The token uses URL-safe base64 encoding.
277    ///
278    /// # Arguments
279    /// * `length` - The desired length of the token in bytes (before encoding)
280    ///              Minimum 16 bytes (128 bits) for security
281    ///
282    /// # Returns
283    /// A URL-safe base64 encoded token string
284    pub fn generate_secure_token(length: usize) -> String {
285        // Ensure minimum entropy of 128 bits (16 bytes)
286        let token_length = length.max(16);
287
288        // Generate random bytes
289        let mut rng = rand::thread_rng();
290        let token_bytes: Vec<u8> = (0..token_length).map(|_| rng.gen()).collect();
291
292        // Encode as URL-safe base64
293        general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes)
294    }
295
296    /// Generate a secure session token
297    ///
298    /// Creates a session token with 256 bits of entropy (32 bytes)
299    pub fn generate_session_token() -> String {
300        Self::generate_secure_token(32)
301    }
302
303    /// Generate a secure API key
304    ///
305    /// Creates an API key with mixed alphanumeric characters and symbols
306    /// for maximum entropy in a readable format
307    pub fn generate_api_key() -> String {
308        let mut rng = rand::thread_rng();
309
310        // Use a mix of character sets for high entropy
311        const CHARSET: &[u8] =
312            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
313        const KEY_LENGTH: usize = 32;
314
315        let key: String = (0..KEY_LENGTH)
316            .map(|_| {
317                let idx = rng.gen_range(0..CHARSET.len());
318                CHARSET[idx] as char
319            })
320            .collect();
321
322        key
323    }
324
325    /// Authenticate a request with bearer token
326    pub async fn authenticate(&self, authorization: Option<&str>) -> Result<AuthContext> {
327        if !self.config.enabled {
328            // Authentication disabled, allow all requests
329            return Ok(AuthContext {
330                authenticated: true,
331                client_id: Some("anonymous".to_string()),
332                scopes: vec!["*".to_string()],
333                resource_indicators: vec!["*".to_string()],
334            });
335        }
336
337        // Extract bearer token
338        let token = match authorization {
339            Some(auth) if auth.starts_with("Bearer ") => auth.trim_start_matches("Bearer ").trim(),
340            _ => return Ok(AuthContext::unauthenticated()),
341        };
342
343        // Check cache first
344        let token_hash = self.hash_token(token);
345        if let Some(cached) = self.check_cache(&token_hash).await {
346            return Ok(self.context_from_token(&cached.token));
347        }
348
349        // Validate token
350        let access_token = self.validate_token(token).await?;
351
352        // Cache the result
353        self.cache_token(token_hash, access_token.clone()).await;
354
355        Ok(self.context_from_token(&access_token))
356    }
357
358    /// Validate an access token
359    async fn validate_token(&self, token: &str) -> Result<AccessToken> {
360        // For now, implement local validation
361        // In production, this would call the validation endpoint
362
363        // Special handling for test tokens (using constant-time comparison)
364        if Self::constant_time_compare(token, "test-token-123") {
365            return Ok(AccessToken {
366                token: token.to_string(),
367                token_type: TokenType::Bearer,
368                expires_at: None,
369                scopes: vec![
370                    "*".to_string(),
371                    "security:scan".to_string(),
372                    "security:verify".to_string(),
373                    "info:read".to_string(),
374                ],
375                resource_indicators: vec![self.server_resource_id.clone()],
376                client_id: "test-client".to_string(),
377            });
378        }
379
380        // Parse JWT or opaque token
381        let parts: Vec<&str> = token.split('.').collect();
382        if parts.len() != 3 {
383            anyhow::bail!("Invalid token format");
384        }
385
386        // Decode header to check algorithm
387        let header_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[0])?;
388        let header: JwtHeader = serde_json::from_slice(&header_bytes)?;
389
390        // Decode payload
391        let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[1])?;
392        let claims: TokenClaims = serde_json::from_slice(&payload_bytes)?;
393
394        // Verify signature if required
395        if self.config.require_signature_verification {
396            // Check algorithm
397            match header.alg.as_deref() {
398                Some("HS256") => {
399                    // HMAC-SHA256 verification
400                    if let Some(secret) = &self.config.jwt_secret {
401                        // Decode the secret
402                        let secret_bytes = general_purpose::STANDARD.decode(secret)?;
403
404                        // Create HMAC instance
405                        type HmacSha256 = Hmac<Sha256>;
406                        let mut mac = HmacSha256::new_from_slice(&secret_bytes)?;
407
408                        // Update with header.payload
409                        mac.update(format!("{}.{}", parts[0], parts[1]).as_bytes());
410
411                        // Decode provided signature
412                        let signature_bytes = general_purpose::URL_SAFE_NO_PAD.decode(parts[2])?;
413
414                        // Verify signature
415                        mac.verify_slice(&signature_bytes)?;
416                    } else {
417                        anyhow::bail!("JWT secret not configured for signature verification");
418                    }
419                },
420                Some("none") => {
421                    anyhow::bail!(
422                        "Unsigned tokens not allowed when signature verification is required"
423                    );
424                },
425                Some(alg) => {
426                    anyhow::bail!("Unsupported algorithm: {}. Only HS256 is supported", alg);
427                },
428                None => {
429                    anyhow::bail!("Missing algorithm in JWT header");
430                },
431            }
432        }
433
434        // Check expiration
435        if let Some(exp) = claims.exp {
436            let now = std::time::SystemTime::now()
437                .duration_since(std::time::UNIX_EPOCH)?
438                .as_secs();
439            if exp < now {
440                anyhow::bail!("Token expired");
441            }
442        }
443
444        // Check issuer
445        if !self.config.trusted_issuers.is_empty() {
446            if let Some(iss) = &claims.iss {
447                if !self.config.trusted_issuers.contains(iss) {
448                    anyhow::bail!("Untrusted issuer");
449                }
450            }
451        }
452
453        // Extract resource indicators
454        let resource_indicators = claims
455            .resource_indicators
456            .or_else(|| claims.aud.clone().map(|a| vec![a]))
457            .unwrap_or_default();
458
459        // Validate resource indicators if enabled
460        if self.config.validate_resource_indicators
461            && !resource_indicators.is_empty()
462            && !resource_indicators.contains(&self.server_resource_id)
463            && !resource_indicators.contains(&"*".to_string())
464        {
465            anyhow::bail!("Token not valid for this resource server");
466        }
467
468        Ok(AccessToken {
469            token: token.to_string(),
470            token_type: TokenType::Bearer,
471            expires_at: claims.exp.map(|exp| {
472                Instant::now()
473                    + Duration::from_secs(
474                        exp.saturating_sub(
475                            std::time::SystemTime::now()
476                                .duration_since(std::time::UNIX_EPOCH)
477                                .unwrap_or_default()
478                                .as_secs(),
479                        ),
480                    )
481            }),
482            scopes: claims
483                .scope
484                .map(|s| s.split_whitespace().map(String::from).collect())
485                .unwrap_or_default(),
486            resource_indicators,
487            client_id: claims.client_id.unwrap_or_else(|| "unknown".to_string()),
488        })
489    }
490
491    /// Check if an operation is authorized
492    pub fn authorize_tool(&self, auth: &AuthContext, tool_name: &str) -> Result<()> {
493        if !auth.authenticated {
494            anyhow::bail!("Authentication required");
495        }
496
497        // Get required scopes for this tool
498        let required_scopes = self
499            .config
500            .required_scopes
501            .tools
502            .get(tool_name)
503            .or(Some(&self.config.required_scopes.default))
504            .cloned()
505            .unwrap_or_default();
506
507        if !auth.has_any_scope(&required_scopes) {
508            anyhow::bail!("Insufficient scope for tool: {}", tool_name);
509        }
510
511        Ok(())
512    }
513
514    /// Check if resource access is authorized
515    pub fn authorize_resource(&self, auth: &AuthContext, resource_uri: &str) -> Result<()> {
516        if !auth.authenticated {
517            anyhow::bail!("Authentication required");
518        }
519
520        // Get required scopes for this resource
521        let required_scopes = self
522            .config
523            .required_scopes
524            .resources
525            .get(resource_uri)
526            .or(Some(&self.config.required_scopes.default))
527            .cloned()
528            .unwrap_or_default();
529
530        if !auth.has_any_scope(&required_scopes) {
531            anyhow::bail!("Insufficient scope for resource: {}", resource_uri);
532        }
533
534        // Check resource indicators
535        if self.config.validate_resource_indicators
536            && !auth.has_resource_access(&self.server_resource_id)
537        {
538            anyhow::bail!("Token not authorized for this resource server");
539        }
540
541        Ok(())
542    }
543
544    /// Hash a token for caching
545    fn hash_token(&self, token: &str) -> String {
546        let mut hasher = Sha256::new();
547        hasher.update(token.as_bytes());
548        format!("{:x}", hasher.finalize())
549    }
550
551    /// Check token cache
552    async fn check_cache(&self, token_hash: &str) -> Option<CachedToken> {
553        let cache = self.token_cache.read().await;
554
555        cache.get(token_hash).and_then(|cached| {
556            let age = cached.validated_at.elapsed();
557            if age < Duration::from_secs(self.config.cache_ttl_seconds) {
558                Some(cached.clone())
559            } else {
560                None
561            }
562        })
563    }
564
565    /// Cache a validated token
566    async fn cache_token(&self, token_hash: String, token: AccessToken) {
567        let mut cache = self.token_cache.write().await;
568
569        cache.insert(
570            token_hash,
571            CachedToken {
572                token,
573                validated_at: Instant::now(),
574                validation_result: TokenValidation::Valid,
575            },
576        );
577
578        // Clean up old entries
579        let now = Instant::now();
580        let ttl = Duration::from_secs(self.config.cache_ttl_seconds);
581        cache.retain(|_, v| now.duration_since(v.validated_at) < ttl);
582    }
583
584    /// Create auth context from token
585    fn context_from_token(&self, token: &AccessToken) -> AuthContext {
586        AuthContext {
587            authenticated: true,
588            client_id: Some(token.client_id.clone()),
589            scopes: token.scopes.clone(),
590            resource_indicators: token.resource_indicators.clone(),
591        }
592    }
593}
594
595/// JWT token claims (simplified)
596#[derive(Debug, Deserialize)]
597#[allow(missing_docs)] // Internal JWT implementation detail
598struct JwtHeader {
599    #[serde(default)]
600    alg: Option<String>,
601
602    #[serde(default)]
603    #[allow(dead_code)] // JWT type field, kept for standard compliance
604    typ: Option<String>,
605}
606
607#[derive(Debug, Deserialize)]
608#[allow(missing_docs)] // Internal JWT implementation detail
609struct TokenClaims {
610    #[serde(default)]
611    iss: Option<String>,
612
613    #[serde(default)]
614    #[allow(dead_code)] // JWT standard claim, kept for compliance
615    sub: Option<String>,
616
617    #[serde(default)]
618    aud: Option<String>,
619
620    #[serde(default)]
621    exp: Option<u64>,
622
623    #[serde(default)]
624    #[allow(dead_code)] // JWT issued-at claim, kept for compliance
625    iat: Option<u64>,
626
627    #[serde(default)]
628    scope: Option<String>,
629
630    #[serde(default)]
631    client_id: Option<String>,
632
633    /// Resource indicators from RFC 8707
634    #[serde(default)]
635    resource_indicators: Option<Vec<String>>,
636}
637
638// Clone implementations
639impl Clone for CachedToken {
640    fn clone(&self) -> Self {
641        Self {
642            token: self.token.clone(),
643            validated_at: self.validated_at,
644            validation_result: match &self.validation_result {
645                TokenValidation::Valid => TokenValidation::Valid,
646                TokenValidation::Expired => TokenValidation::Expired,
647                TokenValidation::Invalid => TokenValidation::Invalid,
648                TokenValidation::InsufficientScope => TokenValidation::InsufficientScope,
649                TokenValidation::ResourceMismatch => TokenValidation::ResourceMismatch,
650            },
651        }
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_auth_context() {
661        let ctx = AuthContext {
662            authenticated: true,
663            client_id: Some("test-client".to_string()),
664            scopes: vec!["mcp:read".to_string(), "mcp:write".to_string()],
665            resource_indicators: vec!["kindlyguard".to_string()],
666        };
667
668        assert!(ctx.has_scope("mcp:read"));
669        assert!(ctx.has_scope("mcp:write"));
670        assert!(!ctx.has_scope("mcp:admin"));
671
672        assert!(ctx.has_any_scope(&["mcp:read".to_string()]));
673        assert!(ctx.has_any_scope(&["mcp:admin".to_string(), "mcp:write".to_string()]));
674
675        assert!(ctx.has_resource_access("kindlyguard"));
676        assert!(!ctx.has_resource_access("other-server"));
677    }
678
679    #[test]
680    fn test_unauthenticated_context() {
681        let ctx = AuthContext::unauthenticated();
682        assert!(!ctx.authenticated);
683        assert!(ctx.scopes.is_empty());
684        assert!(ctx.resource_indicators.is_empty());
685    }
686
687    #[test]
688    fn test_constant_time_comparison() {
689        // Test equal strings
690        assert!(AuthManager::constant_time_compare("secret123", "secret123"));
691
692        // Test different strings
693        assert!(!AuthManager::constant_time_compare(
694            "secret123",
695            "secret124"
696        ));
697        assert!(!AuthManager::constant_time_compare("secret", "secrets"));
698        assert!(!AuthManager::constant_time_compare("", "secret"));
699        assert!(!AuthManager::constant_time_compare("secret", ""));
700
701        // Test empty strings
702        assert!(AuthManager::constant_time_compare("", ""));
703    }
704
705    #[test]
706    fn test_secure_token_generation() {
707        // Test minimum length enforcement
708        let token1 = AuthManager::generate_secure_token(8);
709        let token2 = AuthManager::generate_secure_token(16);
710        let token3 = AuthManager::generate_secure_token(32);
711
712        // Base64 encoding increases length by ~4/3
713        assert!(token1.len() >= 21); // 16 bytes * 4/3 ≈ 21 chars
714        assert!(token2.len() >= 21); // 16 bytes * 4/3 ≈ 21 chars
715        assert!(token3.len() >= 42); // 32 bytes * 4/3 ≈ 42 chars
716
717        // Test uniqueness
718        let token4 = AuthManager::generate_secure_token(32);
719        assert_ne!(token3, token4);
720
721        // Test session token
722        let session1 = AuthManager::generate_session_token();
723        let session2 = AuthManager::generate_session_token();
724        assert!(session1.len() >= 42); // 32 bytes * 4/3 ≈ 42 chars
725        assert_ne!(session1, session2);
726    }
727
728    #[test]
729    fn test_api_key_generation() {
730        let key1 = AuthManager::generate_api_key();
731        let key2 = AuthManager::generate_api_key();
732
733        // Test length
734        assert_eq!(key1.len(), 32);
735        assert_eq!(key2.len(), 32);
736
737        // Test uniqueness
738        assert_ne!(key1, key2);
739
740        // Test character set (should contain mix of alphanumeric and symbols)
741        let has_upper = key1.chars().any(|c| c.is_uppercase());
742        let has_lower = key1.chars().any(|c| c.is_lowercase());
743        let has_digit = key1.chars().any(|c| c.is_numeric());
744        let has_symbol = key1.chars().any(|c| "!@#$%^&*-_=+".contains(c));
745
746        // API keys should have high character diversity
747        assert!(has_upper || has_lower || has_digit || has_symbol);
748    }
749
750    #[test]
751    fn test_token_entropy() {
752        // Generate multiple tokens and check for sufficient randomness
753        let mut tokens = Vec::new();
754        for _ in 0..100 {
755            tokens.push(AuthManager::generate_secure_token(16));
756        }
757
758        // All tokens should be unique
759        let unique_count = tokens
760            .iter()
761            .collect::<std::collections::HashSet<_>>()
762            .len();
763        assert_eq!(unique_count, 100);
764
765        // Check character distribution (basic entropy test)
766        let all_chars: String = tokens.join("");
767        let char_freq = all_chars
768            .chars()
769            .fold(std::collections::HashMap::new(), |mut map, c| {
770                *map.entry(c).or_insert(0) += 1;
771                map
772            });
773
774        // Should have good character distribution (at least 20 different characters)
775        assert!(char_freq.len() >= 20);
776    }
777}