1use crate::{Error, Result};
23use jsonwebtoken::{Algorithm, DecodingKey, TokenData, Validation, decode};
24use serde::{Deserialize, Serialize};
25use std::fmt;
26use std::sync::Arc;
27
28const MIN_SECRET_LENGTH: usize = 32;
30
31const MIN_UNIQUE_CHARS: usize = 8;
34
35const MIN_CHAR_CLASSES: usize = 3;
38
39fn validate_secret_entropy(secret: &str) -> std::result::Result<(), String> {
46 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 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 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#[derive(Debug, Serialize, Deserialize, Clone)]
94pub struct Claims {
95 pub sub: String,
97 pub exp: usize,
99 #[serde(default)]
101 pub iat: usize,
102 #[serde(default)]
104 pub iss: Option<String>,
105 #[serde(default)]
107 pub aud: Option<String>,
108 #[serde(default)]
110 pub scopes: Vec<String>,
111}
112
113impl Claims {
114 #[must_use]
124 pub fn has_scope(&self, scope: &str) -> bool {
125 self.scopes.iter().any(|s| s == scope || s == "*")
126 }
127
128 #[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#[cfg(feature = "http")]
148#[derive(Debug, Clone, Default)]
149pub struct ToolAuthorization {
150 pub allow_unknown_with_admin: bool,
152}
153
154#[cfg(feature = "http")]
155impl ToolAuthorization {
156 const KNOWN_TOOLS: &'static [(&'static str, &'static str)] = &[
158 ("subcog_capture", "write"),
160 ("subcog_enrich", "write"),
161 ("subcog_consolidate", "write"),
162 ("prompt_save", "write"),
163 ("prompt_delete", "write"),
164 ("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 ("subcog_sync", "admin"),
174 ("subcog_reindex", "admin"),
175 ];
176
177 #[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 if self.allow_unknown_with_admin {
201 Some("admin")
202 } else {
203 None
204 }
205 }
206
207 #[must_use]
212 #[allow(dead_code)] pub fn is_known_tool(tool_name: &str) -> bool {
214 Self::KNOWN_TOOLS.iter().any(|(name, _)| *name == tool_name)
215 }
216
217 #[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, }
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct JwtConfig {
240 secret: String,
242 issuer: Option<String>,
244 audience: Option<String>,
246}
247
248impl JwtConfig {
249 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_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 #[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 #[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 #[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#[derive(Clone)]
315pub struct JwtAuthenticator {
316 decoding_key: Arc<DecodingKey>,
318 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 #[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 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 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 #[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 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 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 let config = JwtConfig::new("short");
518 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 #[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 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 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 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 assert_eq!(auth.required_scope("subcog_sync"), Some("admin"));
608 assert_eq!(auth.required_scope("subcog_reindex"), Some("admin"));
609
610 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 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 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 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 assert!(auth.is_authorized(&superuser, "subcog_recall"));
681 assert!(auth.is_authorized(&superuser, "subcog_capture"));
682 assert!(auth.is_authorized(&superuser, "subcog_sync"));
683 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 assert!(auth.is_authorized(&multi_scope_user, "subcog_recall"));
703 assert!(auth.is_authorized(&multi_scope_user, "subcog_capture"));
704 assert!(!auth.is_authorized(&multi_scope_user, "subcog_sync"));
706 }
707
708 #[test]
711 fn test_entropy_validation_good_base64_secret() {
712 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 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 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 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 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 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 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 let result = validate_secret_entropy("AAAAAAAAAAAAAAAAAAAAAAAAAAAA+/==");
768 assert!(
770 result.is_err(),
771 "Two classes (uppercase + special) should fail"
772 );
773 }
774}