Skip to main content

subcog/mcp/
auth.rs

1//! JWT authentication for MCP HTTP transport (SEC-H1).
2//!
3//! Provides bearer token validation for the MCP HTTP server.
4//! The stdio transport does NOT require authentication.
5//!
6//! # Configuration
7//!
8//! Set these environment variables for JWT validation:
9//!
10//! - `SUBCOG_MCP_JWT_SECRET`: Required. The secret key for HS256 validation.
11//! - `SUBCOG_MCP_JWT_ISSUER`: Optional. Expected issuer claim.
12//! - `SUBCOG_MCP_JWT_AUDIENCE`: Optional. Expected audience claim.
13//!
14//! # Example
15//!
16//! ```bash
17//! export SUBCOG_MCP_JWT_SECRET="your-secret-key-min-32-chars-long"
18//! export SUBCOG_MCP_JWT_ISSUER="https://auth.example.com"
19//! subcog serve --transport http --port 3000
20//! ```
21
22use crate::{Error, Result};
23use jsonwebtoken::{Algorithm, DecodingKey, TokenData, Validation, decode};
24use serde::{Deserialize, Serialize};
25use std::fmt;
26use std::sync::Arc;
27
28/// Minimum secret key length for security.
29const MIN_SECRET_LENGTH: usize = 32;
30
31/// Minimum number of unique characters for entropy validation.
32/// A 32+ character secret with fewer than 8 unique chars is likely weak.
33const MIN_UNIQUE_CHARS: usize = 8;
34
35/// Minimum character classes required (HIGH-SEC-004).
36/// At least 3 of: lowercase, uppercase, digits, special chars.
37const MIN_CHAR_CLASSES: usize = 3;
38
39/// Validates that a secret has sufficient entropy (not just length).
40///
41/// Checks for (HIGH-SEC-004):
42/// - Minimum unique character diversity (8+ unique chars)
43/// - Character class diversity (3+ of: lowercase, uppercase, digits, special)
44/// - Not obviously sequential/weak patterns
45fn validate_secret_entropy(secret: &str) -> std::result::Result<(), String> {
46    // Check unique character count
47    let unique_chars: std::collections::HashSet<char> = secret.chars().collect();
48    if unique_chars.len() < MIN_UNIQUE_CHARS {
49        return Err(format!(
50            "JWT secret has insufficient entropy: only {} unique characters (minimum: {})",
51            unique_chars.len(),
52            MIN_UNIQUE_CHARS
53        ));
54    }
55
56    // HIGH-SEC-004: Check character class diversity
57    let has_lowercase = secret.chars().any(|c| c.is_ascii_lowercase());
58    let has_uppercase = secret.chars().any(|c| c.is_ascii_uppercase());
59    let has_digit = secret.chars().any(|c| c.is_ascii_digit());
60    let has_special = secret
61        .chars()
62        .any(|c| c.is_ascii_punctuation() || c == '+' || c == '/' || c == '=');
63
64    let char_class_count = usize::from(has_lowercase)
65        + usize::from(has_uppercase)
66        + usize::from(has_digit)
67        + usize::from(has_special);
68
69    if char_class_count < MIN_CHAR_CLASSES {
70        return Err(format!(
71            "JWT secret has insufficient character diversity: {char_class_count} character classes (minimum: {MIN_CHAR_CLASSES}). \
72             Use a mix of lowercase, uppercase, digits, and special characters. \
73             Recommended: openssl rand -base64 32"
74        ));
75    }
76
77    // Check for obvious weak patterns
78    let lowercase = secret.to_lowercase();
79    let weak_patterns = [
80        "password", "secret", "123456", "abcdef", "qwerty", "000000", "111111", "aaaaaa",
81    ];
82
83    for pattern in weak_patterns {
84        if lowercase.contains(pattern) {
85            return Err(format!("JWT secret contains weak pattern '{pattern}'"));
86        }
87    }
88
89    Ok(())
90}
91
92/// JWT claims structure.
93#[derive(Debug, Serialize, Deserialize, Clone)]
94pub struct Claims {
95    /// Subject (user identifier).
96    pub sub: String,
97    /// Expiration time (Unix timestamp).
98    pub exp: usize,
99    /// Issued at time (Unix timestamp).
100    #[serde(default)]
101    pub iat: usize,
102    /// Issuer.
103    #[serde(default)]
104    pub iss: Option<String>,
105    /// Audience.
106    #[serde(default)]
107    pub aud: Option<String>,
108    /// Optional scopes for authorization.
109    #[serde(default)]
110    pub scopes: Vec<String>,
111}
112
113impl Claims {
114    /// Checks if the claims include a specific scope (CRIT-003).
115    ///
116    /// # Arguments
117    ///
118    /// * `scope` - The scope to check for (e.g., "read", "write", "admin").
119    ///
120    /// # Returns
121    ///
122    /// `true` if the claims include the specified scope or the wildcard "*" scope.
123    #[must_use]
124    pub fn has_scope(&self, scope: &str) -> bool {
125        self.scopes.iter().any(|s| s == scope || s == "*")
126    }
127
128    /// Checks if the claims include any of the specified scopes.
129    ///
130    /// # Arguments
131    ///
132    /// * `scopes` - The scopes to check for.
133    ///
134    /// # Returns
135    ///
136    /// `true` if the claims include any of the specified scopes or the wildcard "*" scope.
137    #[must_use]
138    pub fn has_any_scope(&self, scopes: &[&str]) -> bool {
139        scopes.iter().any(|s| self.has_scope(s))
140    }
141}
142
143/// Tool authorization configuration (CRIT-003).
144///
145/// Maps tool names to required scopes for fine-grained access control.
146/// Unknown tools are explicitly denied by returning `None` from `required_scope`.
147#[cfg(feature = "http")]
148#[derive(Debug, Clone, Default)]
149pub struct ToolAuthorization {
150    /// Whether to allow unknown tools with admin scope (default: false, deny unknown).
151    pub allow_unknown_with_admin: bool,
152}
153
154#[cfg(feature = "http")]
155impl ToolAuthorization {
156    /// Known tools and their scopes (compile-time constant for security).
157    const KNOWN_TOOLS: &'static [(&'static str, &'static str)] = &[
158        // Write operations
159        ("subcog_capture", "write"),
160        ("subcog_enrich", "write"),
161        ("subcog_consolidate", "write"),
162        ("prompt_save", "write"),
163        ("prompt_delete", "write"),
164        // Read operations
165        ("subcog_recall", "read"),
166        ("subcog_status", "read"),
167        ("subcog_namespaces", "read"),
168        ("prompt_understanding", "read"),
169        ("prompt_list", "read"),
170        ("prompt_get", "read"),
171        ("prompt_run", "read"),
172        // Admin operations
173        ("subcog_sync", "admin"),
174        ("subcog_reindex", "admin"),
175    ];
176
177    /// Returns the required scope for a tool, or `None` if the tool is unknown.
178    ///
179    /// # Security
180    ///
181    /// Unknown tools return `None` to enforce explicit denial by default.
182    /// This prevents authorization bypass via unrecognized tool names.
183    ///
184    /// Tool scope mapping:
185    /// - `subcog_capture`, `subcog_enrich`, `subcog_consolidate`: "write"
186    /// - `subcog_recall`, `subcog_status`, `subcog_namespaces`, `prompt_understanding`: "read"
187    /// - `subcog_sync`, `subcog_reindex`: "admin"
188    /// - `prompt_save`, `prompt_delete`: "write"
189    /// - `prompt_list`, `prompt_get`, `prompt_run`: "read"
190    /// - Unknown tools: `None` (explicit deny) or "admin" if `allow_unknown_with_admin`
191    #[must_use]
192    pub fn required_scope(&self, tool_name: &str) -> Option<&'static str> {
193        for (name, scope) in Self::KNOWN_TOOLS {
194            if *name == tool_name {
195                return Some(scope);
196            }
197        }
198
199        // Unknown tools: deny by default, require admin if explicitly allowed
200        if self.allow_unknown_with_admin {
201            Some("admin")
202        } else {
203            None
204        }
205    }
206
207    /// Checks if a tool name is known to the authorization system.
208    ///
209    /// This is part of the public API for callers to verify tool names
210    /// before making authorization requests.
211    #[must_use]
212    #[allow(dead_code)] // Public API - may be used by external callers
213    pub fn is_known_tool(tool_name: &str) -> bool {
214        Self::KNOWN_TOOLS.iter().any(|(name, _)| *name == tool_name)
215    }
216
217    /// Checks if claims authorize access to a tool.
218    ///
219    /// # Arguments
220    ///
221    /// * `claims` - The JWT claims to check.
222    /// * `tool_name` - The name of the tool being called.
223    ///
224    /// # Returns
225    ///
226    /// `true` if the tool is known and claims include the required scope.
227    /// Returns `false` for unknown tools (explicit deny).
228    #[must_use]
229    pub fn is_authorized(&self, claims: &Claims, tool_name: &str) -> bool {
230        match self.required_scope(tool_name) {
231            Some(required) => claims.has_scope(required),
232            None => false, // Unknown tools are explicitly denied
233        }
234    }
235}
236
237/// JWT authentication configuration.
238#[derive(Debug, Clone)]
239pub struct JwtConfig {
240    /// Secret key for HS256 validation.
241    secret: String,
242    /// Expected issuer (optional).
243    issuer: Option<String>,
244    /// Expected audience (optional).
245    audience: Option<String>,
246}
247
248impl JwtConfig {
249    /// Creates a new JWT configuration from environment variables.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if `SUBCOG_MCP_JWT_SECRET` is not set, too short, or
254    /// has insufficient entropy.
255    pub fn from_env() -> Result<Self> {
256        let secret =
257            std::env::var("SUBCOG_MCP_JWT_SECRET").map_err(|_| Error::OperationFailed {
258                operation: "jwt_config".to_string(),
259                cause: "SUBCOG_MCP_JWT_SECRET environment variable not set".to_string(),
260            })?;
261
262        if secret.len() < MIN_SECRET_LENGTH {
263            return Err(Error::OperationFailed {
264                operation: "jwt_config".to_string(),
265                cause: format!(
266                    "JWT secret must be at least {MIN_SECRET_LENGTH} characters (got {})",
267                    secret.len()
268                ),
269            });
270        }
271
272        // Validate entropy (SEC-H1: entropy validation)
273        validate_secret_entropy(&secret).map_err(|cause| Error::OperationFailed {
274            operation: "jwt_config".to_string(),
275            cause,
276        })?;
277
278        let issuer = std::env::var("SUBCOG_MCP_JWT_ISSUER").ok();
279        let audience = std::env::var("SUBCOG_MCP_JWT_AUDIENCE").ok();
280
281        Ok(Self {
282            secret,
283            issuer,
284            audience,
285        })
286    }
287
288    /// Creates a JWT configuration with explicit values (for testing).
289    #[must_use]
290    pub fn new(secret: impl Into<String>) -> Self {
291        Self {
292            secret: secret.into(),
293            issuer: None,
294            audience: None,
295        }
296    }
297
298    /// Sets the expected issuer.
299    #[must_use]
300    pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
301        self.issuer = Some(issuer.into());
302        self
303    }
304
305    /// Sets the expected audience.
306    #[must_use]
307    pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
308        self.audience = Some(audience.into());
309        self
310    }
311}
312
313/// JWT authenticator for validating bearer tokens.
314#[derive(Clone)]
315pub struct JwtAuthenticator {
316    /// Decoding key.
317    decoding_key: Arc<DecodingKey>,
318    /// Validation settings.
319    validation: Validation,
320}
321
322impl fmt::Debug for JwtAuthenticator {
323    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
324        f.debug_struct("JwtAuthenticator")
325            .field("validation", &self.validation)
326            .finish_non_exhaustive()
327    }
328}
329
330impl JwtAuthenticator {
331    /// Creates a new JWT authenticator from configuration.
332    #[must_use]
333    pub fn new(config: &JwtConfig) -> Self {
334        let decoding_key = Arc::new(DecodingKey::from_secret(config.secret.as_bytes()));
335
336        let mut validation = Validation::new(Algorithm::HS256);
337        validation.validate_exp = true;
338
339        if let Some(issuer) = &config.issuer {
340            validation.set_issuer(&[issuer]);
341        }
342
343        if let Some(audience) = &config.audience {
344            validation.set_audience(&[audience]);
345        }
346
347        Self {
348            decoding_key,
349            validation,
350        }
351    }
352
353    /// Validates a bearer token and returns the claims.
354    ///
355    /// # Arguments
356    ///
357    /// * `token` - The JWT token (without "Bearer " prefix).
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if the token is invalid, expired, or fails validation.
362    pub fn validate(&self, token: &str) -> Result<Claims> {
363        let token_data: TokenData<Claims> = decode(token, &self.decoding_key, &self.validation)
364            .map_err(|e| {
365                tracing::warn!(error = %e, "JWT validation failed");
366                Error::Unauthorized(format!("Invalid token: {e}"))
367            })?;
368
369        tracing::debug!(
370            sub = %token_data.claims.sub,
371            scopes = ?token_data.claims.scopes,
372            "JWT validated successfully"
373        );
374
375        Ok(token_data.claims)
376    }
377
378    /// Extracts and validates a bearer token from an Authorization header.
379    ///
380    /// # Arguments
381    ///
382    /// * `auth_header` - The full Authorization header value (e.g., `Bearer <token>`).
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the header format is invalid or token validation fails.
387    pub fn validate_header(&self, auth_header: &str) -> Result<Claims> {
388        let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
389            Error::Unauthorized("Invalid Authorization header format".to_string())
390        })?;
391
392        self.validate(token)
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use jsonwebtoken::{EncodingKey, Header, encode};
400
401    fn create_test_token(claims: &Claims, secret: &str) -> String {
402        encode(
403            &Header::default(),
404            claims,
405            &EncodingKey::from_secret(secret.as_bytes()),
406        )
407        .expect("Failed to encode test token")
408    }
409
410    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
411    fn valid_claims() -> Claims {
412        Claims {
413            sub: "test-user".to_string(),
414            exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() as usize,
415            iat: chrono::Utc::now().timestamp() as usize,
416            iss: None,
417            aud: None,
418            scopes: vec!["read".to_string(), "write".to_string()],
419        }
420    }
421
422    // Tests that use JwtConfig::new don't need env vars
423    #[test]
424    fn test_validate_valid_token() {
425        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
426        let config = JwtConfig::new(secret);
427        let authenticator = JwtAuthenticator::new(&config);
428
429        let claims = valid_claims();
430        let token = create_test_token(&claims, secret);
431
432        let result = authenticator.validate(&token);
433        assert!(result.is_ok());
434        let validated_claims = result.expect("Should validate");
435        assert_eq!(validated_claims.sub, "test-user");
436    }
437
438    #[test]
439    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
440    fn test_validate_expired_token() {
441        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
442        let config = JwtConfig::new(secret);
443        let authenticator = JwtAuthenticator::new(&config);
444
445        let mut claims = valid_claims();
446        claims.exp = (chrono::Utc::now() - chrono::Duration::hours(1)).timestamp() as usize;
447        let token = create_test_token(&claims, secret);
448
449        let result = authenticator.validate(&token);
450        assert!(result.is_err());
451    }
452
453    #[test]
454    fn test_validate_wrong_secret() {
455        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
456        let wrong_secret = "a-different-long-secret-key-that-is-32-chars";
457        let config = JwtConfig::new(secret);
458        let authenticator = JwtAuthenticator::new(&config);
459
460        let claims = valid_claims();
461        let token = create_test_token(&claims, wrong_secret);
462
463        let result = authenticator.validate(&token);
464        assert!(result.is_err());
465    }
466
467    #[test]
468    fn test_validate_header() {
469        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
470        let config = JwtConfig::new(secret);
471        let authenticator = JwtAuthenticator::new(&config);
472
473        let claims = valid_claims();
474        let token = create_test_token(&claims, secret);
475        let header = format!("Bearer {token}");
476
477        let result = authenticator.validate_header(&header);
478        assert!(result.is_ok());
479    }
480
481    #[test]
482    fn test_validate_header_invalid_format() {
483        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
484        let config = JwtConfig::new(secret);
485        let authenticator = JwtAuthenticator::new(&config);
486
487        let result = authenticator.validate_header("Basic dXNlcjpwYXNz");
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn test_issuer_validation() {
493        let secret = "a-very-long-secret-key-that-is-at-least-32-chars";
494        let config = JwtConfig::new(secret).with_issuer("expected-issuer");
495        let authenticator = JwtAuthenticator::new(&config);
496
497        // Token with wrong issuer should fail
498        let mut claims = valid_claims();
499        claims.iss = Some("wrong-issuer".to_string());
500        let token = create_test_token(&claims, secret);
501
502        let result = authenticator.validate(&token);
503        assert!(result.is_err());
504
505        // Token with correct issuer should pass
506        claims.iss = Some("expected-issuer".to_string());
507        let token = create_test_token(&claims, secret);
508
509        let result = authenticator.validate(&token);
510        assert!(result.is_ok());
511    }
512
513    #[test]
514    fn test_jwt_config_short_secret_validation() {
515        // Use JwtConfig::new directly to test validation logic
516        // We test from_env indirectly since env vars are unsafe in tests
517        let config = JwtConfig::new("short");
518        // The config is created, but authenticator will use short key
519        // The actual security check is at from_env level
520        let _authenticator = JwtAuthenticator::new(&config);
521    }
522
523    #[test]
524    fn test_jwt_config_builder() {
525        let config = JwtConfig::new("secret")
526            .with_issuer("my-issuer")
527            .with_audience("my-audience");
528
529        assert_eq!(config.issuer, Some("my-issuer".to_string()));
530        assert_eq!(config.audience, Some("my-audience".to_string()));
531    }
532
533    // CRIT-003: Tool Authorization Tests
534
535    #[test]
536    fn test_claims_has_scope() {
537        let claims = Claims {
538            sub: "test-user".to_string(),
539            exp: 0,
540            iat: 0,
541            iss: None,
542            aud: None,
543            scopes: vec!["read".to_string(), "write".to_string()],
544        };
545
546        assert!(claims.has_scope("read"));
547        assert!(claims.has_scope("write"));
548        assert!(!claims.has_scope("admin"));
549    }
550
551    #[test]
552    fn test_claims_has_scope_wildcard() {
553        let claims = Claims {
554            sub: "admin-user".to_string(),
555            exp: 0,
556            iat: 0,
557            iss: None,
558            aud: None,
559            scopes: vec!["*".to_string()],
560        };
561
562        // Wildcard should match any scope
563        assert!(claims.has_scope("read"));
564        assert!(claims.has_scope("write"));
565        assert!(claims.has_scope("admin"));
566        assert!(claims.has_scope("anything"));
567    }
568
569    #[test]
570    fn test_claims_has_any_scope() {
571        let claims = Claims {
572            sub: "test-user".to_string(),
573            exp: 0,
574            iat: 0,
575            iss: None,
576            aud: None,
577            scopes: vec!["read".to_string()],
578        };
579
580        assert!(claims.has_any_scope(&["read", "write"]));
581        assert!(claims.has_any_scope(&["admin", "read"]));
582        assert!(!claims.has_any_scope(&["write", "admin"]));
583    }
584
585    #[cfg(feature = "http")]
586    #[test]
587    fn test_tool_authorization_required_scopes() {
588        let auth = ToolAuthorization::default();
589
590        // Write operations
591        assert_eq!(auth.required_scope("subcog_capture"), Some("write"));
592        assert_eq!(auth.required_scope("subcog_enrich"), Some("write"));
593        assert_eq!(auth.required_scope("subcog_consolidate"), Some("write"));
594        assert_eq!(auth.required_scope("prompt_save"), Some("write"));
595        assert_eq!(auth.required_scope("prompt_delete"), Some("write"));
596
597        // Read operations
598        assert_eq!(auth.required_scope("subcog_recall"), Some("read"));
599        assert_eq!(auth.required_scope("subcog_status"), Some("read"));
600        assert_eq!(auth.required_scope("subcog_namespaces"), Some("read"));
601        assert_eq!(auth.required_scope("prompt_understanding"), Some("read"));
602        assert_eq!(auth.required_scope("prompt_list"), Some("read"));
603        assert_eq!(auth.required_scope("prompt_get"), Some("read"));
604        assert_eq!(auth.required_scope("prompt_run"), Some("read"));
605
606        // Admin operations
607        assert_eq!(auth.required_scope("subcog_sync"), Some("admin"));
608        assert_eq!(auth.required_scope("subcog_reindex"), Some("admin"));
609
610        // Unknown tools return None (explicitly denied by default)
611        assert_eq!(auth.required_scope("unknown_tool"), None);
612    }
613
614    #[cfg(feature = "http")]
615    #[test]
616    fn test_tool_authorization_is_authorized() {
617        let auth = ToolAuthorization::default();
618
619        // User with read scope
620        let read_user = Claims {
621            sub: "reader".to_string(),
622            exp: 0,
623            iat: 0,
624            iss: None,
625            aud: None,
626            scopes: vec!["read".to_string()],
627        };
628
629        assert!(auth.is_authorized(&read_user, "subcog_recall"));
630        assert!(auth.is_authorized(&read_user, "subcog_status"));
631        assert!(!auth.is_authorized(&read_user, "subcog_capture"));
632        assert!(!auth.is_authorized(&read_user, "subcog_sync"));
633
634        // User with write scope
635        let write_user = Claims {
636            sub: "writer".to_string(),
637            exp: 0,
638            iat: 0,
639            iss: None,
640            aud: None,
641            scopes: vec!["write".to_string()],
642        };
643
644        assert!(auth.is_authorized(&write_user, "subcog_capture"));
645        assert!(auth.is_authorized(&write_user, "prompt_save"));
646        assert!(!auth.is_authorized(&write_user, "subcog_recall"));
647        assert!(!auth.is_authorized(&write_user, "subcog_sync"));
648
649        // User with admin scope
650        let admin_user = Claims {
651            sub: "admin".to_string(),
652            exp: 0,
653            iat: 0,
654            iss: None,
655            aud: None,
656            scopes: vec!["admin".to_string()],
657        };
658
659        assert!(auth.is_authorized(&admin_user, "subcog_sync"));
660        assert!(auth.is_authorized(&admin_user, "subcog_reindex"));
661        assert!(!auth.is_authorized(&admin_user, "subcog_capture"));
662        assert!(!auth.is_authorized(&admin_user, "subcog_recall"));
663    }
664
665    #[cfg(feature = "http")]
666    #[test]
667    fn test_tool_authorization_wildcard_scope() {
668        let auth = ToolAuthorization::default();
669
670        let superuser = Claims {
671            sub: "superuser".to_string(),
672            exp: 0,
673            iat: 0,
674            iss: None,
675            aud: None,
676            scopes: vec!["*".to_string()],
677        };
678
679        // Wildcard should authorize all known tools
680        assert!(auth.is_authorized(&superuser, "subcog_recall"));
681        assert!(auth.is_authorized(&superuser, "subcog_capture"));
682        assert!(auth.is_authorized(&superuser, "subcog_sync"));
683        // Unknown tools are explicitly denied regardless of scope
684        assert!(!auth.is_authorized(&superuser, "unknown_tool"));
685    }
686
687    #[cfg(feature = "http")]
688    #[test]
689    fn test_tool_authorization_multiple_scopes() {
690        let auth = ToolAuthorization::default();
691
692        let multi_scope_user = Claims {
693            sub: "multi".to_string(),
694            exp: 0,
695            iat: 0,
696            iss: None,
697            aud: None,
698            scopes: vec!["read".to_string(), "write".to_string()],
699        };
700
701        // Should have access to both read and write operations
702        assert!(auth.is_authorized(&multi_scope_user, "subcog_recall"));
703        assert!(auth.is_authorized(&multi_scope_user, "subcog_capture"));
704        // But not admin
705        assert!(!auth.is_authorized(&multi_scope_user, "subcog_sync"));
706    }
707
708    // HIGH-SEC-004: Character class diversity tests
709
710    #[test]
711    fn test_entropy_validation_good_base64_secret() {
712        // Base64 output from `openssl rand -base64 32` has 3+ character classes
713        let result = validate_secret_entropy("aB3+XyZ9/Qr7mN2pK5tL8vW0jH4gF6sD=");
714        assert!(result.is_ok(), "Base64 secret should pass: {result:?}");
715    }
716
717    #[test]
718    fn test_entropy_validation_all_lowercase_fails() {
719        // Only 1 character class: lowercase
720        let result = validate_secret_entropy("abcdefghijklmnopqrstuvwxyzabcdef");
721        assert!(result.is_err(), "All lowercase should fail");
722        assert!(result.unwrap_err().contains("character diversity"));
723    }
724
725    #[test]
726    fn test_entropy_validation_all_uppercase_fails() {
727        // Only 1 character class: uppercase
728        let result = validate_secret_entropy("ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF");
729        assert!(result.is_err(), "All uppercase should fail");
730    }
731
732    #[test]
733    fn test_entropy_validation_all_digits_fails() {
734        // Only 1 character class: digits
735        let result = validate_secret_entropy("12345678901234567890123456789012");
736        assert!(result.is_err(), "All digits should fail");
737    }
738
739    #[test]
740    fn test_entropy_validation_two_classes_fails() {
741        // Only 2 character classes: lowercase + uppercase
742        let result = validate_secret_entropy("abcdefghijklmnopABCDEFGHIJKLMNOP");
743        assert!(result.is_err(), "Two classes should fail (need 3+)");
744    }
745
746    #[test]
747    fn test_entropy_validation_three_classes_passes() {
748        // 3 character classes: lowercase + uppercase + digits (no weak patterns)
749        let result = validate_secret_entropy("xYmNpQrStUvWxYz0192837465XYZMNP");
750        assert!(result.is_ok(), "Three classes should pass: {result:?}");
751    }
752
753    #[test]
754    fn test_entropy_validation_weak_pattern_still_fails() {
755        // Has 3+ character classes but contains weak pattern
756        let result = validate_secret_entropy("Password123!@#$%^&*()_+-=[]{}|");
757        assert!(
758            result.is_err(),
759            "Weak pattern should fail even with good diversity"
760        );
761        assert!(result.unwrap_err().contains("weak pattern"));
762    }
763
764    #[test]
765    fn test_entropy_validation_special_chars_count() {
766        // Verify special chars include base64 characters (+, /, =)
767        let result = validate_secret_entropy("AAAAAAAAAAAAAAAAAAAAAAAAAAAA+/==");
768        // Has uppercase + special (+ / =), but only 2 classes - should fail
769        assert!(
770            result.is_err(),
771            "Two classes (uppercase + special) should fail"
772        );
773    }
774}