1use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12use std::collections::HashMap;
13use std::sync::OnceLock;
14
15use crate::Result;
16use crate::config::AuthConfig;
17
18static AUTO_JWT_SECRET: OnceLock<String> = OnceLock::new();
21
22fn get_or_generate_jwt_secret() -> &'static str {
24 AUTO_JWT_SECRET.get_or_init(|| {
25 use rand::RngCore;
26 let mut bytes = [0u8; 32];
27 rand::thread_rng().fill_bytes(&mut bytes);
28 let secret: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
29 tracing::warn!(
30 "No jwt_secret configured — generated a random secret. \
31 JWTs signed by external services will fail validation. \
32 Set [auth] jwt_secret in what.toml or WHAT_AUTH_JWT_SECRET env var."
33 );
34 secret
35 })
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct JwtClaims {
42 #[serde(default)]
44 pub exp: Option<u64>,
45 #[serde(default)]
46 pub iat: Option<u64>,
47 #[serde(default)]
48 pub sub: Option<String>,
49
50 #[serde(flatten)]
52 pub custom: HashMap<String, Value>,
53}
54
55impl JwtClaims {
56 pub fn to_context(&self, claim_names: &[String]) -> HashMap<String, Value> {
58 let mut context = HashMap::new();
59
60 if let Some(sub) = &self.sub {
62 context.insert("sub".to_string(), json!(sub));
63 }
64 if let Some(exp) = self.exp {
65 context.insert("exp".to_string(), json!(exp));
66 }
67
68 for name in claim_names {
70 if let Some(value) = self.custom.get(name) {
71 context.insert(name.clone(), value.clone());
72 }
73 }
74
75 context
76 }
77
78 pub fn is_expired(&self) -> bool {
80 if let Some(exp) = self.exp {
81 let now = std::time::SystemTime::now()
82 .duration_since(std::time::UNIX_EPOCH)
83 .map(|d| d.as_secs())
84 .unwrap_or(0);
85 exp < now
86 } else {
87 false }
89 }
90}
91
92#[derive(Clone)]
94pub struct AuthHandler {
95 config: AuthConfig,
96}
97
98impl AuthHandler {
99 pub fn new(config: AuthConfig) -> Self {
101 Self { config }
102 }
103
104 pub fn from_config_with_env(mut config: AuthConfig) -> Self {
106 if let Ok(val) = std::env::var("WHAT_AUTH_ENABLED") {
108 config.enabled = val.parse().unwrap_or(config.enabled);
109 }
110 if let Ok(val) = std::env::var("WHAT_AUTH_LOGIN_ENDPOINT") {
111 config.login_endpoint = Some(val);
112 }
113 if let Ok(val) = std::env::var("WHAT_AUTH_LOGOUT_ENDPOINT") {
114 config.logout_endpoint = Some(val);
115 }
116 if let Ok(val) = std::env::var("WHAT_AUTH_JWT_SECRET") {
117 config.jwt_secret = Some(val);
118 }
119 if let Ok(val) = std::env::var("WHAT_AUTH_JWT_COOKIE_NAME") {
120 config.jwt_cookie_name = val;
121 }
122 if let Ok(val) = std::env::var("WHAT_AUTH_LOGIN_PATH") {
123 config.login_path = val;
124 }
125 if let Ok(val) = std::env::var("WHAT_AUTH_AFTER_LOGIN") {
126 config.after_login = val;
127 }
128
129 Self { config }
130 }
131
132 pub fn is_enabled(&self) -> bool {
134 self.config.enabled
135 }
136
137 pub fn is_protected(&self, path: &str) -> bool {
139 if !self.config.enabled {
140 return false;
141 }
142
143 let normalized = normalize_request_path(path);
148 for pattern in &self.config.protected_paths {
149 if pattern_matches(pattern, &normalized) {
150 return true;
151 }
152 }
153 false
154 }
155
156 pub fn login_path(&self) -> &str {
158 &self.config.login_path
159 }
160
161 pub fn after_login_path(&self) -> &str {
163 &self.config.after_login
164 }
165
166 pub fn login_endpoint(&self) -> Option<&str> {
168 self.config.login_endpoint.as_deref()
169 }
170
171 pub fn logout_endpoint(&self) -> Option<&str> {
173 self.config.logout_endpoint.as_deref()
174 }
175
176 pub fn jwt_cookie_name(&self) -> &str {
178 &self.config.jwt_cookie_name
179 }
180
181 pub fn jwt_claims(&self) -> &[String] {
183 &self.config.jwt_claims
184 }
185
186 pub fn parse_jwt_cookie(&self, cookie_header: Option<&str>) -> Option<String> {
188 cookie_header.and_then(|header| {
189 header
190 .split(';')
191 .map(|s| s.trim())
192 .find(|s| s.starts_with(&format!("{}=", self.config.jwt_cookie_name)))
193 .map(|s| s[self.config.jwt_cookie_name.len() + 1..].to_string())
194 })
195 }
196
197 pub fn decode_jwt(&self, token: &str) -> Result<JwtClaims> {
203 let secret = match self.config.jwt_secret {
204 Some(ref s) => s.as_str(),
205 None => get_or_generate_jwt_secret(),
206 };
207 let key = DecodingKey::from_secret(secret.as_bytes());
208 let validation = Validation::new(Algorithm::HS256);
209 let token_data = decode::<JwtClaims>(token, &key, &validation)?;
210 Ok(token_data.claims)
211 }
212
213 pub fn build_jwt_cookie(&self, token: &str, max_age: i64, secure: bool) -> String {
215 let mut cookie = format!(
216 "{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}",
217 self.config.jwt_cookie_name, token, max_age
218 );
219
220 if secure {
221 cookie.push_str("; Secure");
222 }
223
224 cookie
225 }
226
227 pub fn build_clear_cookie(&self) -> String {
229 format!(
230 "{}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
231 self.config.jwt_cookie_name
232 )
233 }
234}
235
236pub(crate) fn normalize_request_path(path: &str) -> String {
240 let mut out: Vec<&str> = Vec::new();
241 for seg in path.split('/') {
242 match seg {
243 "" | "." => {}
244 ".." => {
245 out.pop();
246 }
247 s => out.push(s),
248 }
249 }
250 let base = format!("/{}", out.join("/"));
251 if base != "/" && path.ends_with('/') {
254 format!("{base}/")
255 } else {
256 base
257 }
258}
259
260fn pattern_matches(pattern: &str, path: &str) -> bool {
266 if pattern.ends_with("/**") {
267 let prefix = &pattern[..pattern.len() - 3];
268 path.starts_with(prefix)
269 } else if pattern.ends_with("/*") {
270 let prefix = &pattern[..pattern.len() - 1];
273 path.starts_with(prefix) && !path[prefix.len()..].contains('/')
274 } else {
275 pattern == path
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct UserContext {
283 pub authenticated: bool,
285 #[serde(flatten)]
287 pub claims: HashMap<String, Value>,
288}
289
290impl UserContext {
291 pub fn unauthenticated() -> Self {
293 Self {
294 authenticated: false,
295 claims: HashMap::new(),
296 }
297 }
298
299 pub fn from_claims(claims: HashMap<String, Value>) -> Self {
301 Self {
302 authenticated: true,
303 claims,
304 }
305 }
306
307 pub fn to_context(&self) -> Value {
309 let mut map = serde_json::Map::new();
310 map.insert("authenticated".to_string(), json!(self.authenticated));
311
312 for (key, value) in &self.claims {
314 map.insert(key.clone(), value.clone());
315 }
316
317 Value::Object(map)
318 }
319
320 pub fn roles(&self) -> Vec<String> {
323 self.claims
324 .get("roles")
325 .or_else(|| self.claims.get("role"))
326 .map(|v| match v {
327 Value::Array(arr) => arr
328 .iter()
329 .filter_map(|v| v.as_str().map(String::from))
330 .collect(),
331 Value::String(s) => s.split(',').map(|r| r.trim().to_string()).collect(),
332 _ => Vec::new(),
333 })
334 .unwrap_or_default()
335 }
336
337 pub fn sub(&self) -> Option<String> {
339 self.claims.get("sub").and_then(|v| v.as_str().map(String::from))
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn test_pattern_matches() {
349 assert!(pattern_matches("/admin", "/admin"));
351 assert!(!pattern_matches("/admin", "/admin/users"));
352
353 assert!(pattern_matches("/admin/*", "/admin/users"));
355 assert!(pattern_matches("/admin/*", "/admin/settings"));
356 assert!(!pattern_matches("/admin/*", "/admin/users/123"));
357 assert!(!pattern_matches("/admin/*", "/admin"));
358
359 assert!(pattern_matches("/api/**", "/api/v1"));
361 assert!(pattern_matches("/api/**", "/api/v1/users"));
362 assert!(pattern_matches("/api/**", "/api/v1/users/123"));
363 }
364
365 #[test]
366 fn test_normalize_request_path() {
367 assert_eq!(normalize_request_path("/admin/users"), "/admin/users");
368 assert_eq!(normalize_request_path("//admin/users"), "/admin/users");
370 assert_eq!(normalize_request_path("/./admin/users"), "/admin/users");
371 assert_eq!(normalize_request_path("/admin//users"), "/admin/users");
372 assert_eq!(normalize_request_path("/public/../admin/x"), "/admin/x");
373 assert_eq!(normalize_request_path("/admin/../public"), "/public");
374 assert_eq!(normalize_request_path("/"), "/");
375 assert_eq!(normalize_request_path("/dashboard/"), "/dashboard/");
377 assert_eq!(normalize_request_path("//dashboard//"), "/dashboard/");
378 }
379
380 #[test]
381 fn test_is_protected_resists_path_normalization_bypass() {
382 let config = AuthConfig {
383 enabled: true,
384 protected_paths: vec!["/admin/*".to_string()],
385 ..Default::default()
386 };
387 let handler = AuthHandler::new(config);
388 assert!(handler.is_protected("/admin/users"));
389 assert!(handler.is_protected("//admin/users"));
391 assert!(handler.is_protected("/./admin/users"));
392 assert!(handler.is_protected("/foo/../admin/users"));
393 }
394
395 #[test]
396 fn test_jwt_claims_to_context() {
397 let claims = JwtClaims {
398 exp: Some(1234567890),
399 iat: Some(1234567800),
400 sub: Some("user123".to_string()),
401 custom: [
402 ("email".to_string(), json!("user@example.com")),
403 ("full_name".to_string(), json!("John Doe")),
404 ("role".to_string(), json!("admin")),
405 ]
406 .into_iter()
407 .collect(),
408 };
409
410 let context = claims.to_context(&["email".to_string(), "full_name".to_string()]);
411
412 assert_eq!(context.get("email"), Some(&json!("user@example.com")));
413 assert_eq!(context.get("full_name"), Some(&json!("John Doe")));
414 assert_eq!(context.get("sub"), Some(&json!("user123")));
415 assert!(!context.contains_key("role")); }
417
418 #[test]
419 fn test_user_context() {
420 let unauthenticated = UserContext::unauthenticated();
421 assert!(!unauthenticated.authenticated);
422
423 let authenticated = UserContext::from_claims(
424 [("email".to_string(), json!("user@example.com"))]
425 .into_iter()
426 .collect(),
427 );
428 assert!(authenticated.authenticated);
429
430 let context = authenticated.to_context();
431 assert_eq!(context.get("authenticated"), Some(&json!(true)));
432 assert_eq!(context.get("email"), Some(&json!("user@example.com")));
433 }
434
435 #[test]
436 fn test_auth_handler_parse_jwt_cookie() {
437 let config = AuthConfig {
438 enabled: true,
439 jwt_cookie_name: "w_token".to_string(),
440 ..Default::default()
441 };
442 let handler = AuthHandler::new(config);
443
444 let cookie_header = Some("w_token=abc123; other_cookie=xyz");
446 let result = handler.parse_jwt_cookie(cookie_header);
447 assert_eq!(result, Some("abc123".to_string()));
448
449 let cookie_header = Some("other=value; w_token=def456");
451 let result = handler.parse_jwt_cookie(cookie_header);
452 assert_eq!(result, Some("def456".to_string()));
453
454 let cookie_header = Some("other_cookie=xyz");
456 let result = handler.parse_jwt_cookie(cookie_header);
457 assert!(result.is_none());
458
459 let result = handler.parse_jwt_cookie(None);
461 assert!(result.is_none());
462 }
463
464 #[test]
465 fn test_auth_handler_is_protected() {
466 let config = AuthConfig {
467 enabled: true,
468 protected_paths: vec![
469 "/admin".to_string(),
470 "/admin/*".to_string(),
471 "/api/**".to_string(),
472 ],
473 ..Default::default()
474 };
475 let handler = AuthHandler::new(config);
476
477 assert!(handler.is_protected("/admin"));
479
480 assert!(handler.is_protected("/admin/users"));
482 assert!(handler.is_protected("/admin/settings"));
483
484 assert!(!handler.is_protected("/admin/users/123"));
486
487 assert!(handler.is_protected("/api/v1"));
489 assert!(handler.is_protected("/api/v1/users"));
490 assert!(handler.is_protected("/api/v1/users/123"));
491
492 assert!(!handler.is_protected("/"));
494 assert!(!handler.is_protected("/public"));
495 assert!(!handler.is_protected("/login"));
496 }
497
498 #[test]
499 fn test_auth_handler_disabled() {
500 let config = AuthConfig {
501 enabled: false,
502 protected_paths: vec!["/admin/**".to_string()],
503 ..Default::default()
504 };
505 let handler = AuthHandler::new(config);
506
507 assert!(!handler.is_protected("/admin"));
509 assert!(!handler.is_protected("/admin/users"));
510 assert!(!handler.is_enabled());
511 }
512
513 #[test]
514 fn test_build_jwt_cookie() {
515 let config = AuthConfig {
516 enabled: true,
517 jwt_cookie_name: "w_token".to_string(),
518 ..Default::default()
519 };
520 let handler = AuthHandler::new(config);
521
522 let cookie = handler.build_jwt_cookie("test_token_123", 3600, false);
524 assert!(cookie.contains("w_token=test_token_123"));
525 assert!(cookie.contains("HttpOnly"));
526 assert!(cookie.contains("SameSite=Strict"));
527 assert!(cookie.contains("Path=/"));
528 assert!(cookie.contains("Max-Age=3600"));
529 assert!(!cookie.contains("Secure"));
530
531 let cookie = handler.build_jwt_cookie("test_token_123", 3600, true);
533 assert!(cookie.contains("Secure"));
534 }
535
536 #[test]
537 fn test_build_clear_cookie() {
538 let config = AuthConfig {
539 enabled: true,
540 jwt_cookie_name: "w_token".to_string(),
541 ..Default::default()
542 };
543 let handler = AuthHandler::new(config);
544
545 let cookie = handler.build_clear_cookie();
546 assert!(cookie.contains("w_token="));
547 assert!(cookie.contains("Max-Age=0"));
548 assert!(cookie.contains("HttpOnly"));
549 assert!(cookie.contains("SameSite=Strict"));
550 assert!(cookie.contains("Path=/"));
551 }
552
553 #[test]
554 fn test_jwt_claims_is_expired() {
555 let future_exp = std::time::SystemTime::now()
557 .duration_since(std::time::UNIX_EPOCH)
558 .unwrap()
559 .as_secs()
560 + 3600; let claims = JwtClaims {
563 exp: Some(future_exp),
564 iat: None,
565 sub: None,
566 custom: HashMap::new(),
567 };
568 assert!(!claims.is_expired());
569
570 let past_exp = std::time::SystemTime::now()
572 .duration_since(std::time::UNIX_EPOCH)
573 .unwrap()
574 .as_secs()
575 - 3600; let expired_claims = JwtClaims {
578 exp: Some(past_exp),
579 iat: None,
580 sub: None,
581 custom: HashMap::new(),
582 };
583 assert!(expired_claims.is_expired());
584
585 let no_exp_claims = JwtClaims {
587 exp: None,
588 iat: None,
589 sub: None,
590 custom: HashMap::new(),
591 };
592 assert!(!no_exp_claims.is_expired());
593 }
594
595 #[test]
596 fn test_decode_jwt_with_configured_secret() {
597 use jsonwebtoken::{EncodingKey, Header, encode};
598
599 let secret = "test_secret_123";
600 let config = AuthConfig {
601 enabled: true,
602 jwt_secret: Some(secret.to_string()),
603 ..Default::default()
604 };
605 let handler = AuthHandler::new(config);
606
607 let exp = std::time::SystemTime::now()
609 .duration_since(std::time::UNIX_EPOCH)
610 .unwrap()
611 .as_secs()
612 + 3600;
613
614 let claims = JwtClaims {
615 exp: Some(exp),
616 iat: None,
617 sub: Some("user1".to_string()),
618 custom: [("email".to_string(), json!("a@b.com"))]
619 .into_iter()
620 .collect(),
621 };
622
623 let token = encode(
624 &Header::default(),
625 &claims,
626 &EncodingKey::from_secret(secret.as_bytes()),
627 )
628 .unwrap();
629 let decoded = handler.decode_jwt(&token).unwrap();
630 assert_eq!(decoded.sub, Some("user1".to_string()));
631 assert_eq!(decoded.custom.get("email"), Some(&json!("a@b.com")));
632 }
633
634 #[test]
635 fn test_decode_jwt_rejects_wrong_secret() {
636 use jsonwebtoken::{EncodingKey, Header, encode};
637
638 let config = AuthConfig {
639 enabled: true,
640 jwt_secret: Some("correct_secret".to_string()),
641 ..Default::default()
642 };
643 let handler = AuthHandler::new(config);
644
645 let exp = std::time::SystemTime::now()
646 .duration_since(std::time::UNIX_EPOCH)
647 .unwrap()
648 .as_secs()
649 + 3600;
650
651 let claims = JwtClaims {
652 exp: Some(exp),
653 iat: None,
654 sub: None,
655 custom: HashMap::new(),
656 };
657
658 let token = encode(
660 &Header::default(),
661 &claims,
662 &EncodingKey::from_secret(b"wrong_secret"),
663 )
664 .unwrap();
665 let result = handler.decode_jwt(&token);
666 assert!(
667 result.is_err(),
668 "Should reject JWT signed with wrong secret"
669 );
670 }
671
672 #[test]
673 fn test_decode_jwt_no_secret_uses_auto_generated() {
674 use jsonwebtoken::{EncodingKey, Header, encode};
677
678 let config = AuthConfig {
679 enabled: true,
680 jwt_secret: None, ..Default::default()
682 };
683 let handler = AuthHandler::new(config);
684
685 let exp = std::time::SystemTime::now()
686 .duration_since(std::time::UNIX_EPOCH)
687 .unwrap()
688 .as_secs()
689 + 3600;
690
691 let claims = JwtClaims {
692 exp: Some(exp),
693 iat: None,
694 sub: None,
695 custom: HashMap::new(),
696 };
697
698 let token = encode(
700 &Header::default(),
701 &claims,
702 &EncodingKey::from_secret(b"attacker_secret"),
703 )
704 .unwrap();
705 let result = handler.decode_jwt(&token);
706 assert!(
707 result.is_err(),
708 "Should reject JWT when no secret is configured (auto-generated secret won't match)"
709 );
710 }
711
712 #[test]
713 fn test_auth_handler_getters() {
714 let config = AuthConfig {
715 enabled: true,
716 login_path: "/login".to_string(),
717 after_login: "/dashboard".to_string(),
718 login_endpoint: Some("https://api.example.com/login".to_string()),
719 logout_endpoint: Some("https://api.example.com/logout".to_string()),
720 jwt_cookie_name: "auth_token".to_string(),
721 jwt_claims: vec!["email".to_string(), "name".to_string()],
722 ..Default::default()
723 };
724 let handler = AuthHandler::new(config);
725
726 assert!(handler.is_enabled());
727 assert_eq!(handler.login_path(), "/login");
728 assert_eq!(handler.after_login_path(), "/dashboard");
729 assert_eq!(
730 handler.login_endpoint(),
731 Some("https://api.example.com/login")
732 );
733 assert_eq!(
734 handler.logout_endpoint(),
735 Some("https://api.example.com/logout")
736 );
737 assert_eq!(handler.jwt_cookie_name(), "auth_token");
738 assert_eq!(
739 handler.jwt_claims(),
740 &["email".to_string(), "name".to_string()]
741 );
742 }
743}